feat(wasm-sdk): add prepare_* APIs for idempotent document state transitions#3091
feat(wasm-sdk): add prepare_* APIs for idempotent document state transitions#3091thepastaclaw wants to merge 25 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds a two‑phase Prepare+Execute API to the WASM SDK for document state transitions by introducing prepare_document_create, prepare_document_replace, and prepare_document_delete which build, sign, and return signed StateTransition objects without broadcasting for idempotent retries. Changes
Sequence DiagramsequenceDiagram
participant App as Application
participant SDK as WASM SDK
participant Signer as Signer
participant Platform as Platform
App->>SDK: prepare_document_create(document, identityKey, signer, options)
SDK->>SDK: build_document_create_or_replace_transition(document, entropy?, ...)
SDK->>Signer: sign(stateTransition)
Signer-->>SDK: signed StateTransition
SDK-->>App: return signed StateTransition
App->>SDK: broadcastStateTransition(signedST)
SDK->>Platform: submit signed ST
Platform-->>SDK: accept/confirm
SDK-->>App: broadcast acknowledgment
App->>SDK: waitForResponse(signedST)
SDK->>Platform: query ST status
Platform-->>SDK: confirmed/result
SDK-->>App: final result
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
packages/wasm-sdk/src/state_transitions/document.rs (2)
465-521: Consider extracting shared option-parsing logic to reduce duplication.
prepare_document_create(lines 469–506) duplicates nearly all of the extraction logic fromdocument_create(lines 111–147): document, entropy, identity key, signer, contract fetch, document type, and settings. The same pattern applies toprepare_document_replacevsdocument_replace, andprepare_document_deletevsdocument_delete.A private helper (e.g.,
extract_create_options(options) → (Document, [u8;32], IdentityPublicKey, Signer, DataContract, DocumentType, Option<PutSettings>)) for each operation variant would let both the all-in-one and prepare methods share the parsing/validation code, reducing the surface area for divergence bugs.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 465 - 521, Multiple methods duplicate option parsing/validation (prepare_document_create vs document_create and similar prepare_/document_ pairs); extract the repeated logic into a private helper (e.g., extract_document_create_options) that performs DocumentWasm::try_from_options + Document conversion, entropy validation and conversion to [u8;32], IdentityPublicKeyWasm::try_from_options -> IdentityPublicKey, IdentitySignerWasm::try_from_options (or signer wrapper), calls self.get_or_fetch_contract(contract_id).await, resolves document_type via get_document_type, and parses settings via try_from_options_optional; have both prepare_document_create and document_create call this helper and return the tuple (Document, [u8;32], IdentityPublicKey, IdentitySignerWasm/Signer, DataContract, DocumentType, Option<PutSettingsInput/Into>) to eliminate duplication and keep behavior identical.
1121-1122: Minor: prefermap_oroveris_some()+unwrap()for the revision check.The double-call to
document.revision()with anunwrap()is safe (guarded byis_some()), but a more idiomatic pattern avoids the rawunwrap():♻️ Suggested simplification
- let transition = if document.revision().is_some() - && document.revision().unwrap() != INITIAL_REVISION - { + let transition = if document + .revision() + .map_or(false, |rev| rev != INITIAL_REVISION) + {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 1121 - 1122, Replace the is_some() + unwrap() pattern when building the transition with a single map_or call on document.revision(): compute the boolean condition as document.revision().map_or(false, |r| r != INITIAL_REVISION) (or equivalent map_or_else) and use that in the if that assigns transition so you no longer call revision() twice or unwrap; update the branch that currently reads the revision check to use this mapped result (refer to document.revision() and INITIAL_REVISION in the transition assignment).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Around line 465-521: Multiple methods duplicate option parsing/validation
(prepare_document_create vs document_create and similar prepare_/document_
pairs); extract the repeated logic into a private helper (e.g.,
extract_document_create_options) that performs DocumentWasm::try_from_options +
Document conversion, entropy validation and conversion to [u8;32],
IdentityPublicKeyWasm::try_from_options -> IdentityPublicKey,
IdentitySignerWasm::try_from_options (or signer wrapper), calls
self.get_or_fetch_contract(contract_id).await, resolves document_type via
get_document_type, and parses settings via try_from_options_optional; have both
prepare_document_create and document_create call this helper and return the
tuple (Document, [u8;32], IdentityPublicKey, IdentitySignerWasm/Signer,
DataContract, DocumentType, Option<PutSettingsInput/Into>) to eliminate
duplication and keep behavior identical.
- Around line 1121-1122: Replace the is_some() + unwrap() pattern when building
the transition with a single map_or call on document.revision(): compute the
boolean condition as document.revision().map_or(false, |r| r !=
INITIAL_REVISION) (or equivalent map_or_else) and use that in the if that
assigns transition so you no longer call revision() twice or unwrap; update the
branch that currently reads the revision check to use this mapped result (refer
to document.revision() and INITIAL_REVISION in the transition assignment).
shumkov
left a comment
There was a problem hiding this comment.
please provide a snippet of code which doesn't work
|
@shumkov — here's the code snippet showing what doesn't work: The Problem// Current API: documentCreate() is atomic (nonce bump + sign + broadcast + wait)
try {
await sdk.documents.create({ document, identityKey, signer });
} catch (err) {
if (isTimeoutError(err)) {
// The ST was broadcast, but waitForResponse timed out (504 from DAPI gateway).
// Did it land on Platform? We don't know.
//
// Our only option is to retry — but documentCreate() will:
// 1. Fetch a NEW nonce (old nonce + 1)
// 2. Build a NEW StateTransition with different bytes
// 3. Sign and broadcast this NEW ST
//
// If the first ST DID land, we now have TWO documents (double post).
// There is no way to rebroadcast the original ST because
// documentCreate() never exposes it to the caller.
await sdk.documents.create({ document, identityKey, signer }); // DUPLICATE
}
}This is the exact bug PastaPastaPasta/yappr#260 hit in production — DAPI gateway 504s caused double-posting. The workaround was ~200 lines of manual ST construction: // What the app had to do: manually build the full ST chain
const createTransition = new DocumentCreateTransition(document, nonce + 1n, null, null);
const batched = new BatchedTransition(createTransition.toDocumentTransition());
const batchTransition = BatchTransition.fromBatchedTransitions([batched], ownerId, 0);
const st = batchTransition.toStateTransition();
st.setIdentityContractNonce(nonce + 1n);
st.sign(PrivateKey.fromWIF(wif), identityKey);
// Cache bytes before broadcasting for safe retry
const stBytes = st.toBytes();
localStorage.setItem(cacheKey, base64Encode(stBytes));
await sdk.wasm.broadcastStateTransition(st);
await sdk.wasm.waitForResponse(st);
// On timeout: reload cached bytes, rebroadcast SAME ST (idempotent)
const cached = StateTransition.fromBytes(localStorage.getItem(cacheKey));
await sdk.wasm.broadcastStateTransition(cached);With the const st = await sdk.prepareDocumentCreate({ document, identityKey, signer });
const stBytes = st.toBytes(); // cache for retry safety
await sdk.broadcastStateTransition(st);
await sdk.waitForResponse(st);
// On timeout: StateTransition.fromBytes(stBytes) → rebroadcast |
|
@shumkov Here's the concrete problem — the high-level // Current API — double-post on timeout
try {
const result = await sdk.documentCreate({
document, identityKey, signer,
settings: { timeoutMs: 30_000 }
});
} catch (e) {
// If this was a 504 timeout, did the ST land on Platform?
// We don't know, and we can't rebroadcast the same ST.
//
// Retrying calls documentCreate() again, which:
// 1. Fetches a NEW nonce (bumped)
// 2. Builds a NEW StateTransition
// 3. Signs and broadcasts it
// → If the first one DID land, we now have a DUPLICATE document.
const result2 = await sdk.documentCreate({
document, identityKey, signer,
settings: { timeoutMs: 30_000 }
});
}This actively causes double-posting in production (see yappr#260 for the ~200-line manual workaround). With // New two-phase API — idempotent retry
const st = await sdk.prepareDocumentCreate({
document, identityKey, signer
});
const stBytes = st.toBytes(); // cache for retry safety
try {
await sdk.broadcastStateTransition(st);
await sdk.waitForResponse(st);
} catch (e) {
if (isTimeout(e)) {
// Rebroadcast the IDENTICAL ST — same nonce, same signature
// Platform will either accept it (first didn't land) or
// reject as duplicate (first succeeded). No double-post possible.
const cached = StateTransition.fromBytes(stBytes);
await sdk.broadcastStateTransition(cached);
await sdk.waitForResponse(cached);
}
}The key difference: 🤖 This was generated by an automated review bot. |
|
Addressed the For the option-parsing duplication extraction: I agree there's shared logic between the 🤖 This was generated by an automated review bot. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
packages/wasm-sdk/src/state_transitions/document.rs (2)
1108-1108: Move the inlineusedeclaration to module-level importsPlacing
useinside a function body is valid Rust but non-idiomatic and inconsistent with the rest of the file where all trait imports are at the top.♻️ Proposed fix
-async fn build_document_create_or_replace_transition(...) ... { - use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; - - let new_identity_contract_nonce = ...Move to the module-level import block (near the other
use dash_sdk::dpp::data_contract::...imports):+use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/wasm-sdk/src/state_transitions/document.rs` at line 1108, The inline use statement for the trait DocumentTypeV0Getters should be moved from inside the function body to the module-level import block alongside the other dash_sdk::dpp::data_contract::... imports; update the top-of-file use imports to include use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters and remove the inline declaration so trait methods resolve consistently and match the file's import style.
465-521: Consider extracting shared option-parsing logic to eliminate duplication across prepare/non-prepare variants
prepare_document_create(lines 470–506) anddocument_create(lines 111–147) contain identical blocks for: document extraction, entropy validation, identity-key extraction, signer extraction, contract fetch, document-type lookup, and settings extraction. Same duplication exists betweendocument_replace/prepare_document_replaceanddocument_delete/prepare_document_delete. Extracting these into small helpers (e.g.parse_document_create_opts,parse_delete_document_spec) would make the diverging parts (broadcast vs. return ST) obvious and reduce maintenance surface.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 465 - 521, Extract the duplicated option-parsing and validation logic into small helper functions and call them from both prepare and non-prepare variants; e.g., add a helper parse_document_create_opts that accepts PrepareDocumentCreateOptionsJs (or the shared options type) and returns the parsed Document (or DocumentWasm), a 32-byte entropy array, IdentityPublicKey, IdentitySignerWasm (or signer), the fetched DataContract, the DocumentType, and optional PutSettingsInput/converted settings; then replace the duplicated blocks in prepare_document_create and document_create to call parse_document_create_opts and use its results (similarly introduce parse_document_replace_opts and parse_document_delete_opts and use them from prepare_document_replace/document_replace and prepare_document_delete/document_delete) so the prepare* functions only differ by building/returning the state transition while non-prepare variants handle broadcasting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Around line 564-607: prepare_document_replace currently delegates to
build_document_create_or_replace_transition which will treat a Document with
revision == None or INITIAL_REVISION as a create (mutating ID/entropy); add an
explicit guard in prepare_document_replace that reads the Document's revision
(from Document or DocumentWasm) and returns a WasmSdkError (or appropriate error
variant) if revision is None or equals INITIAL_REVISION, so only documents with
a non‑initial revision are allowed to proceed to
build_document_create_or_replace_transition; reference prepare_document_replace,
Document/DocumentWasm.revision(), INITIAL_REVISION, and
build_document_create_or_replace_transition when adding the check and error
return.
---
Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Line 1108: The inline use statement for the trait DocumentTypeV0Getters should
be moved from inside the function body to the module-level import block
alongside the other dash_sdk::dpp::data_contract::... imports; update the
top-of-file use imports to include use
dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters
and remove the inline declaration so trait methods resolve consistently and
match the file's import style.
- Around line 465-521: Extract the duplicated option-parsing and validation
logic into small helper functions and call them from both prepare and
non-prepare variants; e.g., add a helper parse_document_create_opts that accepts
PrepareDocumentCreateOptionsJs (or the shared options type) and returns the
parsed Document (or DocumentWasm), a 32-byte entropy array, IdentityPublicKey,
IdentitySignerWasm (or signer), the fetched DataContract, the DocumentType, and
optional PutSettingsInput/converted settings; then replace the duplicated blocks
in prepare_document_create and document_create to call
parse_document_create_opts and use its results (similarly introduce
parse_document_replace_opts and parse_document_delete_opts and use them from
prepare_document_replace/document_replace and
prepare_document_delete/document_delete) so the prepare* functions only differ
by building/returning the state transition while non-prepare variants handle
broadcasting.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/wasm-sdk/src/state_transitions/document.rs (1)
466-523: Substantial duplication betweenprepare_document_create/prepare_document_deleteand their non-prepare counterpartsThe option-extraction preamble (document, entropy, identity_key, signer, contract_id, document_type_name, data_contract, document_type, settings) is ~40 lines repeated verbatim in each prepare/non-prepare pair. Similarly
prepare_document_deleteanddocument_deleteshare identical document-field extraction and builder construction.A small private helper that returns the extracted options as a struct (or tuple) would eliminate the duplication and reduce the maintenance surface. The PR comments note this as a follow-up, but it's worth tracking.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 466 - 523, The prepare/document functions duplicate option extraction and validation; create a small private helper (e.g., extract_document_options or DocumentOptions) that, given &self and the PrepareDocumentCreateOptionsJs (or generic options), performs: DocumentWasm::try_from_options -> Document, extract and validate entropy into a [u8;32], IdentityPublicKeyWasm::try_from_options -> IdentityPublicKey, IdentitySignerWasm::try_from_options -> signer, derive contract_id and document_type_name, fetch data_contract via self.get_or_fetch_contract(contract_id).await, resolve document_type via get_document_type(&data_contract, &document_type_name)?, and parse optional settings; return a struct with fields (document, document_type, entropy_array, identity_key, signer, data_contract, settings) and update prepare_document_create (and prepare_document_delete/document_create/document_delete) to call this helper and use its returned values when building the state transition.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Around line 466-523: The prepare/document functions duplicate option
extraction and validation; create a small private helper (e.g.,
extract_document_options or DocumentOptions) that, given &self and the
PrepareDocumentCreateOptionsJs (or generic options), performs:
DocumentWasm::try_from_options -> Document, extract and validate entropy into a
[u8;32], IdentityPublicKeyWasm::try_from_options -> IdentityPublicKey,
IdentitySignerWasm::try_from_options -> signer, derive contract_id and
document_type_name, fetch data_contract via
self.get_or_fetch_contract(contract_id).await, resolve document_type via
get_document_type(&data_contract, &document_type_name)?, and parse optional
settings; return a struct with fields (document, document_type, entropy_array,
identity_key, signer, data_contract, settings) and update
prepare_document_create (and
prepare_document_delete/document_create/document_delete) to call this helper and
use its returned values when building the state transition.
|
@thepastaclaw @PastaPastaPasta it's already retring on timeouts. you can configure retries and timeouts passing settings platform/packages/wasm-sdk/src/settings.rs Line 192 in 3f49766 |
|
Here's the problem scenario: // Current API — atomic, no way to retry
try {
await sdk.documentCreate(document, identity, documentType, entropy);
} catch (e) {
if (e.message.includes("timeout")) {
// Cannot retry! Calling documentCreate again would:
// 1. Fetch a new nonce (incremented from the first attempt)
// 2. Build a NEW state transition with different nonce
// 3. The original ST might still be in mempool — now we have a conflict
//
// We also cannot rebroadcast the original ST because
// we never had access to it.
}
}
// With prepare_* API — caller controls retry
const signedST = await sdk.prepareDocumentCreate(document, identity, documentType, entropy);
const txid = signedST.transactionId();
try {
await sdk.broadcastAndWait(signedST);
} catch (e) {
if (e.message.includes("timeout")) {
// Safe to rebroadcast the SAME signed ST
await sdk.broadcastAndWait(signedST);
// Or check if the original went through:
// await sdk.waitForStateTransitionResult(txid);
}
}The core issue is that |
|
Ah, I see — the SDK already has built-in retry logic with configurable The remaining question would be whether there are cases where the caller needs the signed ST for reasons beyond retry (e.g., storing the txid for tracking before broadcast, or broadcasting through a different channel). But if the existing retry mechanism covers the practical use cases thephez was hitting, this PR may not be needed. @PastaPastaPasta @thephez — does the existing retry config in |
|
@shumkov — I looked at the retry mechanism you linked ( But it doesn't cover the case this PR addresses: application-level failure recovery. Consider this scenario:
The SDK's built-in retry can't help here because the retry loop is inside the same With
This is a standard two-phase pattern (prepare + commit) that any app dealing with unreliable networks needs. The SDK's retry covers the happy path; I'm keeping this PR open — it solves a real problem that |
|
I think in my usage, the timeout that bites us is the |
This isn't very consistent with what I've seen in using the sdk in yappr; is the default configuration setup to retry? |
|
Re: your questions about retry behavior: Does Default config: What's actually retried: The retry loop in But — if the So if you're hitting timeouts in yappr where the wait never completes even with retries, it might be that the timeout per-attempt is too short, or the retries are exhausted. The This is exactly why I think |
|
Re: your questions about retry/timeout behavior: Does PutSettingsInput affect wait_for_response? Are retries enabled by default? The real gap (which this PR was trying to address) is that even with retries configured, the retry happens at the DAPI request level — if the broadcast succeeds but |
67793d7 to
a4ad291
Compare
Claw please back this up with code references @shumkov if this is the case, this is a big problem for clients like yappr, and may have been the cause of many of our double posting issues. We also had some issues where the sdk itself would just timeout / fail, and then user would click post again, and then it turns out that the first one worked. IDK how this case was happening if sdk had retries as shown here... (claw maybe look if yappr needs to manually configure in sdk options this retrying? or maybe we are setting some other option and that is clobbering it or something? don't look in latest yappr, look a bit further back in history before our hacky fix for double posts) |
There was a problem hiding this comment.
🧹 Nitpick comments (3)
packages/wasm-sdk/src/state_transitions/document.rs (3)
465-465: Register newprepare*methods inapi-definitions.jsonand regenerate docs.The three new
#[wasm_bindgen]exports (prepareDocumentCreate,prepareDocumentReplace,prepareDocumentDelete) are now part of the public JS/TS API surface but won't appear in the generateddocs.htmlorAI_REFERENCE.mdunless they're added toapi-definitions.jsonand docs are regenerated withpython3 generate_docs.py. Based on learnings, both files are auto-generated from that JSON config and manual edits would be overwritten.Based on learnings: "In
packages/wasm-sdk/, theAI_REFERENCE.mdfile is auto-generated fromapi-definitions.json. Any documentation fixes should be made inapi-definitions.jsonrather than directly inAI_REFERENCE.md", and "WASM SDK (packages/wasm-sdk) WebAssembly bindings must be built with./build.shand documentation kept in sync usingpython3 generate_docs.py".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/wasm-sdk/src/state_transitions/document.rs` at line 465, Add the three new wasm exports to the SDK docs config: update api-definitions.json to register prepareDocumentCreate, prepareDocumentReplace, and prepareDocumentDelete (the #[wasm_bindgen(js_name = "...")] exports in document.rs) with appropriate signatures and descriptions, then regenerate the WebAssembly bindings/docs by running ./build.sh and python3 generate_docs.py so docs.html and AI_REFERENCE.md reflect the new public API surface.
466-522: Consider extracting shared option-parsing logic to reduce duplication.
prepare_document_create(lines 466–522) duplicates ~30 lines of option extraction that are also present indocument_create(lines 107–164). The only meaningful difference between them is the final call —build_document_create_or_replace_transitionvsput_to_platform_and_wait_for_response. Extracting the shared preamble into a private helper struct or a typedDocumentCreateArgswould halve the maintenance surface.This duplication also exists between
prepare_document_replaceanddocument_replace.(Already noted in PR discussion as a known follow-up — flagging here for tracking.)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 466 - 522, prepare_document_create duplicates the same options-parsing preamble found in document_create (and similarly for prepare_document_replace/document_replace); extract that shared logic into a small private helper (e.g., a DocumentCreateArgs or parse_document_create_args function) that returns a struct containing the parsed Document/DocumentWasm, contract_id, document_type_name, entropy_array ([u8;32]), IdentityPublicKey (from IdentityPublicKeyWasm), IdentitySignerWasm, and optional PutSettingsInput, reusing existing helpers like DocumentWasm::try_from_options, IdentityPublicKeyWasm::try_from_options, IdentitySignerWasm::try_from_options, get_or_fetch_contract, get_document_type and try_from_options_optional; then refactor prepare_document_create to call the new helper and pass its fields into build_document_create_or_replace_transition, and refactor document_create to call the same helper and pass its fields into put_to_platform_and_wait_for_response so both flows share the parsed arguments.
1147-1161: Fallback random-entropy path is unreachable from all current callers.When
document_state_transition_entropyisNoneand the document is in create mode (revision == Noneorrevision == INITIAL_REVISION), the code falls into a random-entropy branch that generates a newStdRngand rewrites the document ID. This path is currently dead:
prepare_document_createalways suppliesSome(entropy_array)(validated and required).prepare_document_replacealways suppliesNoneentropy but the revision guard ensures the replace branch is taken.This is not a bug—the helper preserves the flexibility of the original
put_to_platformlogic. However, ifStdRng::from_entropy()has platform-specific behaviour in the WASM target (i.e.,getrandomwith thejsfeature), it's worth a quick sanity check in case a future caller accidentally hits this path.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 1147 - 1161, The fallback branch that generates entropy with StdRng::from_entropy() (used in the document_state_transition_entropy None path and Document::generate_document_id_v0) is effectively unreachable today but may hit WASM where StdRng::from_entropy() behavior differs; update the fallback to be explicit: either (A) mark it unreachable/assert (so callers must supply entropy) or (B) replace StdRng::from_entropy() with a platform-safe entropy source (e.g., OsRng/getrandom) guarded by cfg(target_arch = "wasm32") and return a clear error if secure entropy is unavailable; adjust callers or document this in prepare_document_create / prepare_document_replace and keep references to Document::generate_document_id_v0, document_state_transition_entropy, StdRng::from_entropy, prepare_document_create, prepare_document_replace, and put_to_platform to locate the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Line 465: Add the three new wasm exports to the SDK docs config: update
api-definitions.json to register prepareDocumentCreate, prepareDocumentReplace,
and prepareDocumentDelete (the #[wasm_bindgen(js_name = "...")] exports in
document.rs) with appropriate signatures and descriptions, then regenerate the
WebAssembly bindings/docs by running ./build.sh and python3 generate_docs.py so
docs.html and AI_REFERENCE.md reflect the new public API surface.
- Around line 466-522: prepare_document_create duplicates the same
options-parsing preamble found in document_create (and similarly for
prepare_document_replace/document_replace); extract that shared logic into a
small private helper (e.g., a DocumentCreateArgs or parse_document_create_args
function) that returns a struct containing the parsed Document/DocumentWasm,
contract_id, document_type_name, entropy_array ([u8;32]), IdentityPublicKey
(from IdentityPublicKeyWasm), IdentitySignerWasm, and optional PutSettingsInput,
reusing existing helpers like DocumentWasm::try_from_options,
IdentityPublicKeyWasm::try_from_options, IdentitySignerWasm::try_from_options,
get_or_fetch_contract, get_document_type and try_from_options_optional; then
refactor prepare_document_create to call the new helper and pass its fields into
build_document_create_or_replace_transition, and refactor document_create to
call the same helper and pass its fields into
put_to_platform_and_wait_for_response so both flows share the parsed arguments.
- Around line 1147-1161: The fallback branch that generates entropy with
StdRng::from_entropy() (used in the document_state_transition_entropy None path
and Document::generate_document_id_v0) is effectively unreachable today but may
hit WASM where StdRng::from_entropy() behavior differs; update the fallback to
be explicit: either (A) mark it unreachable/assert (so callers must supply
entropy) or (B) replace StdRng::from_entropy() with a platform-safe entropy
source (e.g., OsRng/getrandom) guarded by cfg(target_arch = "wasm32") and return
a clear error if secure entropy is unavailable; adjust callers or document this
in prepare_document_create / prepare_document_replace and keep references to
Document::generate_document_id_v0, document_state_transition_entropy,
StdRng::from_entropy, prepare_document_create, prepare_document_replace, and
put_to_platform to locate the code.
64ee784 to
a868b10
Compare
a868b10 to
1cee881
Compare
…sitions Add prepare variants for document create, replace, and delete operations that build and sign a StateTransition without broadcasting. This enables idempotent retry patterns where callers can cache the signed ST bytes and rebroadcast on timeout instead of creating duplicates with new nonces. New methods: - prepareDocumentCreate() — build, sign, return ST - prepareDocumentReplace() — build, sign, return ST - prepareDocumentDelete() — build, sign, return ST These pair with the existing broadcastStateTransition() and waitForResponse() methods already exposed in broadcast.rs. Closes dashpay#3090
Addresses CodeRabbit nitpick - more idiomatic Rust pattern that avoids calling revision() twice and the unnecessary unwrap().
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
No blocking issues at HEAD 97953ab. The pre-broadcast nonce-rollback machinery is correctly wired in wasm-sdk's prepare paths and the rs-sdk create/replace dispatcher; remaining concerns are parity gaps (Sdk::document_delete and rs-sdk-ffi delete still don't roll back on local failure) and API-surface hygiene around the new public builders (entropy=None silently rewriting document.id, dispatcher still routes Some(2+) to replace). Several diagnostic nitpicks at the JS→wasm boundary (tokenPaymentInfo unchecked cast, plain-object error path-prefix in prepareDocumentDelete) are convergent across reviewers.
Reviewed commit: 97953ab
🟡 5 suggestion(s) | 💬 4 nitpick(s)
2 additional findings
🟡 suggestion: Swift/C FFI delete path does not opt into the new pre-broadcast nonce-rollback semantics
packages/rs-sdk-ffi/src/document/delete.rs (lines 144-166)
Verified at rs-sdk-ffi/src/document/delete.rs:156-163. The FFI consumer still calls builder.sign(&wrapper.sdk, ...), which internally bumps the identity-contract nonce via get_identity_contract_nonce(.., true, .. ) before delegating to sign_with_nonce. Any failure inside BatchTransition::new_document_deletion_transition_from_document therefore leaves the local nonce cache one ahead of Platform, with no rollback. This is consistent with pre-PR behavior (not a regression), but the wasm-sdk's prepareDocumentDelete now adopts the new allocate-then-rollback pattern (wasm-sdk document.rs:910-948), so the Swift FFI surface now lags behind in nonce-leak hygiene. Worth tracking as a follow-up so the rollback semantics reach all FFI surfaces symmetrically.
💬 nitpick: tokenPaymentInfo crosses the JS→wasm boundary via unchecked_into without an is_object check
packages/wasm-sdk/src/state_transitions/document.rs (lines 125-146)
Verified at document.rs:136-143. try_from_options_optional_token_payment_info rejects only null/undefined and then unchecked_into::<TokenPaymentInfoOptionsJs>() force-casts whatever JS value was supplied before handing it to TokenPaymentInfoWasm::constructor. Because the constructor re-reads via Reflect, callers passing tokenPaymentInfo: 42 or tokenPaymentInfo: "x" get cascading per-field '<field>' is required diagnostics rather than a clear tokenPaymentInfo must be an object. No memory-safety risk — wasm-bindgen's unchecked_into is safe for Rust, but the boundary contract is implicit. The new prepare* APIs widen the surface that goes through this helper, making a clean error worth the small additional check.
💡 Suggested change
if token_payment_info_value.is_null() || token_payment_info_value.is_undefined() {
return Ok(None);
}
if !token_payment_info_value.is_object() {
return Err(WasmSdkError::invalid_argument(
"tokenPaymentInfo must be an object",
));
}
let token_payment_info = TokenPaymentInfoWasm::constructor(
token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(),
)
.map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?;
Ok(Some(token_payment_info.into()))
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 380-393: Sdk::document_delete still leaks the bumped contract nonce on pre-broadcast failures
Verified at delete.rs:385-389. The one-shot `Sdk::document_delete` calls `builder.sign(...)` (which internally allocates and bumps the nonce, then delegates to `sign_with_nonce`) and then runs `ensure_valid_state_transition_structure` before broadcasting. If either step fails, the bumped nonce stays in the cache without rollback. The building blocks added in this PR — `DocumentDeleteTransitionBuilder::sign_with_nonce` (delete.rs:209) and `Sdk::rollback_identity_contract_nonce` (sdk.rs:506) — already let the wasm prepare path handle this correctly (document.rs:910-948). Aligning the native one-shot delete with the same allocate → sign-with-nonce → validate → conditional-rollback pattern closes the parity gap. Today this is mostly latent because batch transition structure validation returns `UnsupportedFeatureError`, but the gap will surface the moment that validator does real work.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 215-238: build_signed_document_create_transition accepts entropy=None and silently rewrites document.id
Verified at put_document.rs:216-238 and resolve_document_create_entropy at lines 72-91. The new strict-create public wrapper takes `document_state_transition_entropy: Option<[u8; 32]>` and forwards `None` through to the shared resolver, which generates fresh entropy via `StdRng::from_entropy()` and overwrites `document.id` via `Document::generate_document_id_v0`. The wasm-sdk caller always passes `Some(...)`, so this branch is unreachable from JS today, but the function is `pub` and is now the canonical fail-fast create entrypoint for native rs-sdk and rs-sdk-ffi consumers. A native caller that constructs a `Document` with a chosen `id` and passes `None` for entropy will receive a transition that mutated their document to embed a different id, with no signal in the API surface that this can happen. Tightening the parameter to `[u8; 32]` (so callers cannot accidentally trigger the regeneration) or returning the resolved entropy/id explicitly would make this safe by construction. The replace wrapper at lines 254-275 correctly hard-codes `None` because entropy is unused there — only the create wrapper needs the change.
- [SUGGESTION] lines 154-200: create-or-replace dispatcher only rejects Some(0); Some(2+) on a 'create' silently produces an unsendable replace
Verified at put_document.rs:167 (`ensure_revision_nonzero`) and lines 293, 19 (`is_document_replace_revision`). The public dispatcher remains the path used by `PutDocument::put_to_platform` (line 342) and only rejects `Some(0)`. Any `Some(rev)` with `rev > INITIAL_REVISION` is routed to the replace branch, so a native caller intending to create a brand-new document but accidentally passing `Some(2)` silently produces a replace transition that can never validate against state — failing only at broadcast, after the contract nonce was bumped (the new rollback narrows the cache impact, but the caller still gets a confusing late-stage error). The new strict wrappers `build_signed_document_create_transition` / `build_signed_document_replace_transition` (lines 215-275) already encode the precise intent the wasm-sdk's `documentCreate`/`documentReplace` enforce. Routing `PutDocument::put_to_platform` through the strict create variant when the document revision is unset/`INITIAL_REVISION` and through the strict replace variant otherwise would give native callers the same fail-fast guard.
- [SUGGESTION] lines 188-199: Failure-path rollback in build_signed_document_create_or_replace_transition is not directly tested
The central correctness claim of this commit — that any pre-broadcast error after the contract nonce is bumped triggers `rollback_identity_contract_nonce` — is exercised at the cache layer (in `nonce_cache_tests`) and end-to-end via the wasm prepare flow, but there is no Rust unit/integration test in `put_document.rs` that drives `build_signed_document_create_or_replace_transition` (or the strict create/replace wrappers) through a deliberately-failing build/sign step and asserts the cached nonce was decremented. A future refactor that drops the `Err` arm at lines 190-198, or accidentally calls `refresh_identity_nonce` instead of `rollback_identity_contract_nonce`, would slip past the unit-test layer. Adding a `#[cfg(test)]` case using a stub signer that returns `Err` (mirroring the mock pattern already used in `nonce_cache_tests`) would pin the contract for both helpers.
In `packages/rs-sdk-ffi/src/document/delete.rs`:
- [SUGGESTION] lines 144-166: Swift/C FFI delete path does not opt into the new pre-broadcast nonce-rollback semantics
Verified at rs-sdk-ffi/src/document/delete.rs:156-163. The FFI consumer still calls `builder.sign(&wrapper.sdk, ...)`, which internally bumps the identity-contract nonce via `get_identity_contract_nonce(.., true, .. )` before delegating to `sign_with_nonce`. Any failure inside `BatchTransition::new_document_deletion_transition_from_document` therefore leaves the local nonce cache one ahead of Platform, with no rollback. This is consistent with pre-PR behavior (not a regression), but the wasm-sdk's `prepareDocumentDelete` now adopts the new allocate-then-rollback pattern (wasm-sdk document.rs:910-948), so the Swift FFI surface now lags behind in nonce-leak hygiene. Worth tracking as a follow-up so the rollback semantics reach all FFI surfaces symmetrically.
| .sign(self, signing_key, signer, platform_version) | ||
| .await?; | ||
|
|
||
| ensure_valid_state_transition_structure(&state_transition, platform_version)?; | ||
|
|
||
| let proof_result = state_transition | ||
| .broadcast_and_wait::<StateTransitionProofResult>(self, put_settings) | ||
| .await?; |
There was a problem hiding this comment.
🟡 Suggestion: Sdk::document_delete still leaks the bumped contract nonce on pre-broadcast failures
Verified at delete.rs:385-389. The one-shot Sdk::document_delete calls builder.sign(...) (which internally allocates and bumps the nonce, then delegates to sign_with_nonce) and then runs ensure_valid_state_transition_structure before broadcasting. If either step fails, the bumped nonce stays in the cache without rollback. The building blocks added in this PR — DocumentDeleteTransitionBuilder::sign_with_nonce (delete.rs:209) and Sdk::rollback_identity_contract_nonce (sdk.rs:506) — already let the wasm prepare path handle this correctly (document.rs:910-948). Aligning the native one-shot delete with the same allocate → sign-with-nonce → validate → conditional-rollback pattern closes the parity gap. Today this is mostly latent because batch transition structure validation returns UnsupportedFeatureError, but the gap will surface the moment that validator does real work.
source: ['claude-general', 'claude-rust-quality']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 380-393: Sdk::document_delete still leaks the bumped contract nonce on pre-broadcast failures
Verified at delete.rs:385-389. The one-shot `Sdk::document_delete` calls `builder.sign(...)` (which internally allocates and bumps the nonce, then delegates to `sign_with_nonce`) and then runs `ensure_valid_state_transition_structure` before broadcasting. If either step fails, the bumped nonce stays in the cache without rollback. The building blocks added in this PR — `DocumentDeleteTransitionBuilder::sign_with_nonce` (delete.rs:209) and `Sdk::rollback_identity_contract_nonce` (sdk.rs:506) — already let the wasm prepare path handle this correctly (document.rs:910-948). Aligning the native one-shot delete with the same allocate → sign-with-nonce → validate → conditional-rollback pattern closes the parity gap. Today this is mostly latent because batch transition structure validation returns `UnsupportedFeatureError`, but the gap will surface the moment that validator does real work.
| #[allow(clippy::too_many_arguments)] | ||
| pub async fn build_signed_document_create_transition<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| document_state_transition_entropy: Option<[u8; 32]>, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| ensure_revision_for_create(document.revision())?; | ||
| build_signed_document_create_or_replace_transition( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| ) | ||
| .await | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: build_signed_document_create_transition accepts entropy=None and silently rewrites document.id
Verified at put_document.rs:216-238 and resolve_document_create_entropy at lines 72-91. The new strict-create public wrapper takes document_state_transition_entropy: Option<[u8; 32]> and forwards None through to the shared resolver, which generates fresh entropy via StdRng::from_entropy() and overwrites document.id via Document::generate_document_id_v0. The wasm-sdk caller always passes Some(...), so this branch is unreachable from JS today, but the function is pub and is now the canonical fail-fast create entrypoint for native rs-sdk and rs-sdk-ffi consumers. A native caller that constructs a Document with a chosen id and passes None for entropy will receive a transition that mutated their document to embed a different id, with no signal in the API surface that this can happen. Tightening the parameter to [u8; 32] (so callers cannot accidentally trigger the regeneration) or returning the resolved entropy/id explicitly would make this safe by construction. The replace wrapper at lines 254-275 correctly hard-codes None because entropy is unused there — only the create wrapper needs the change.
source: ['claude-rust-quality', 'codex-rust-quality']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 215-238: build_signed_document_create_transition accepts entropy=None and silently rewrites document.id
Verified at put_document.rs:216-238 and resolve_document_create_entropy at lines 72-91. The new strict-create public wrapper takes `document_state_transition_entropy: Option<[u8; 32]>` and forwards `None` through to the shared resolver, which generates fresh entropy via `StdRng::from_entropy()` and overwrites `document.id` via `Document::generate_document_id_v0`. The wasm-sdk caller always passes `Some(...)`, so this branch is unreachable from JS today, but the function is `pub` and is now the canonical fail-fast create entrypoint for native rs-sdk and rs-sdk-ffi consumers. A native caller that constructs a `Document` with a chosen `id` and passes `None` for entropy will receive a transition that mutated their document to embed a different id, with no signal in the API surface that this can happen. Tightening the parameter to `[u8; 32]` (so callers cannot accidentally trigger the regeneration) or returning the resolved entropy/id explicitly would make this safe by construction. The replace wrapper at lines 254-275 correctly hard-codes `None` because entropy is unused there — only the create wrapper needs the change.
| pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| document_state_transition_entropy: Option<[u8; 32]>, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| // Reject the always-invalid `Some(0)` revision before allocating any | ||
| // nonce. Strict create/replace intent validation is the job of the | ||
| // dedicated helpers below. | ||
| ensure_revision_nonzero(document.revision())?; | ||
|
|
||
| let owner_id = document.owner_id(); | ||
| let contract_id = document_type.data_contract_id(); | ||
| let new_identity_contract_nonce = sdk | ||
| .get_identity_contract_nonce(owner_id, contract_id, true, settings) | ||
| .await?; | ||
|
|
||
| let result = build_and_sign_create_or_replace_after_nonce( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| new_identity_contract_nonce, | ||
| ) | ||
| .await; | ||
|
|
||
| match result { | ||
| Ok(transition) => Ok(transition), | ||
| Err(err) => { | ||
| sdk.rollback_identity_contract_nonce( | ||
| owner_id, | ||
| contract_id, | ||
| new_identity_contract_nonce, | ||
| ) | ||
| .await; | ||
| Err(err) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: create-or-replace dispatcher only rejects Some(0); Some(2+) on a 'create' silently produces an unsendable replace
Verified at put_document.rs:167 (ensure_revision_nonzero) and lines 293, 19 (is_document_replace_revision). The public dispatcher remains the path used by PutDocument::put_to_platform (line 342) and only rejects Some(0). Any Some(rev) with rev > INITIAL_REVISION is routed to the replace branch, so a native caller intending to create a brand-new document but accidentally passing Some(2) silently produces a replace transition that can never validate against state — failing only at broadcast, after the contract nonce was bumped (the new rollback narrows the cache impact, but the caller still gets a confusing late-stage error). The new strict wrappers build_signed_document_create_transition / build_signed_document_replace_transition (lines 215-275) already encode the precise intent the wasm-sdk's documentCreate/documentReplace enforce. Routing PutDocument::put_to_platform through the strict create variant when the document revision is unset/INITIAL_REVISION and through the strict replace variant otherwise would give native callers the same fail-fast guard.
source: ['claude-general']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 154-200: create-or-replace dispatcher only rejects Some(0); Some(2+) on a 'create' silently produces an unsendable replace
Verified at put_document.rs:167 (`ensure_revision_nonzero`) and lines 293, 19 (`is_document_replace_revision`). The public dispatcher remains the path used by `PutDocument::put_to_platform` (line 342) and only rejects `Some(0)`. Any `Some(rev)` with `rev > INITIAL_REVISION` is routed to the replace branch, so a native caller intending to create a brand-new document but accidentally passing `Some(2)` silently produces a replace transition that can never validate against state — failing only at broadcast, after the contract nonce was bumped (the new rollback narrows the cache impact, but the caller still gets a confusing late-stage error). The new strict wrappers `build_signed_document_create_transition` / `build_signed_document_replace_transition` (lines 215-275) already encode the precise intent the wasm-sdk's `documentCreate`/`documentReplace` enforce. Routing `PutDocument::put_to_platform` through the strict create variant when the document revision is unset/`INITIAL_REVISION` and through the strict replace variant otherwise would give native callers the same fail-fast guard.
| match result { | ||
| Ok(transition) => Ok(transition), | ||
| Err(err) => { | ||
| sdk.rollback_identity_contract_nonce( | ||
| owner_id, | ||
| contract_id, | ||
| new_identity_contract_nonce, | ||
| ) | ||
| .await; | ||
| Err(err) | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Failure-path rollback in build_signed_document_create_or_replace_transition is not directly tested
The central correctness claim of this commit — that any pre-broadcast error after the contract nonce is bumped triggers rollback_identity_contract_nonce — is exercised at the cache layer (in nonce_cache_tests) and end-to-end via the wasm prepare flow, but there is no Rust unit/integration test in put_document.rs that drives build_signed_document_create_or_replace_transition (or the strict create/replace wrappers) through a deliberately-failing build/sign step and asserts the cached nonce was decremented. A future refactor that drops the Err arm at lines 190-198, or accidentally calls refresh_identity_nonce instead of rollback_identity_contract_nonce, would slip past the unit-test layer. Adding a #[cfg(test)] case using a stub signer that returns Err (mirroring the mock pattern already used in nonce_cache_tests) would pin the contract for both helpers.
source: ['claude-rust-quality', 'codex-rust-quality']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 188-199: Failure-path rollback in build_signed_document_create_or_replace_transition is not directly tested
The central correctness claim of this commit — that any pre-broadcast error after the contract nonce is bumped triggers `rollback_identity_contract_nonce` — is exercised at the cache layer (in `nonce_cache_tests`) and end-to-end via the wasm prepare flow, but there is no Rust unit/integration test in `put_document.rs` that drives `build_signed_document_create_or_replace_transition` (or the strict create/replace wrappers) through a deliberately-failing build/sign step and asserts the cached nonce was decremented. A future refactor that drops the `Err` arm at lines 190-198, or accidentally calls `refresh_identity_nonce` instead of `rollback_identity_contract_nonce`, would slip past the unit-test layer. Adding a `#[cfg(test)]` case using a stub signer that returns `Err` (mirroring the mock pattern already used in `nonce_cache_tests`) would pin the contract for both helpers.
| let builder = if let Some(s) = settings { | ||
| builder.with_settings(s) | ||
| } else { | ||
| builder | ||
| }; | ||
|
|
||
| // Pre-allocate the identity-contract nonce so that any pre-broadcast | ||
| // failure (sign or local structure validation) can be rolled back via | ||
| // rs-sdk's `rollback_identity_contract_nonce`. The rollback is | ||
| // conditional: it only adjusts the cache entry if it still equals the | ||
| // nonce allocated here, so it does not clobber concurrent allocations. | ||
| let allocated_nonce = self | ||
| .inner_sdk() | ||
| .get_identity_contract_nonce(owner_id, contract_id, true, settings) | ||
| .await?; | ||
|
|
||
| let state_transition = match builder | ||
| .sign_with_nonce( | ||
| allocated_nonce, | ||
| &identity_key, | ||
| &signer, | ||
| self.inner_sdk().version(), | ||
| ) | ||
| .await | ||
| { | ||
| Ok(st) => st, | ||
| Err(err) => { | ||
| self.inner_sdk() | ||
| .rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) | ||
| .await; | ||
| return Err(err.into()); | ||
| } | ||
| }; | ||
|
|
||
| // Validate structure before handing the ST back, mirroring rs-sdk's | ||
| // pre-broadcast check. For document Batch transitions this currently | ||
| // ends up as a no-op because DPP returns UnsupportedFeatureError until | ||
| // that structure validation is implemented there. | ||
| if let Err(err) = | ||
| ensure_valid_state_transition_structure(&state_transition, self.inner_sdk().version()) | ||
| { | ||
| self.inner_sdk() | ||
| .rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) | ||
| .await; | ||
| return Err(err.into()); | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: prepare_document_delete duplicates the allocate-then-rollback policy inline
Verified at document.rs:910-949. prepare_document_create and prepare_document_replace delegate to rs-sdk's build_signed_document_create_transition / build_signed_document_replace_transition, which centralize the rollback policy. prepare_document_delete reproduces the same dance by hand: get_identity_contract_nonce (lines 915-918), sign_with_nonce (lines 920-927), and two separate match/if-let arms that each call rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) (lines 930-935 and 945-948). Hygiene-only — a future change to the rollback policy must touch two sites, and a forget-to-rollback regression on either branch will surface as silent local-cache drift. Exposing a build_signed_document_delete_transition in rs-sdk that mirrors the create/replace helpers would absorb the wrapping in one place and let any future Swift/FFI consumer share the same implementation.
source: ['claude-rust-quality', 'claude-ffi-engineer']
| } else { | ||
| ( | ||
| IdentifierWasm::try_from_options(&document_js, "id")?.into(), | ||
| IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(), | ||
| IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(), | ||
| try_from_options_with(&document_js, "documentTypeName", |v| { | ||
| try_to_string(v, "documentTypeName") | ||
| })?, | ||
| ) | ||
| }; |
There was a problem hiding this comment.
💬 Nitpick: prepareDocumentDelete plain-object errors lose the document. path prefix
Verified at document.rs:864-873. When the JS caller uses the documented plain-object form ({ document: { id, ownerId, dataContractId, documentTypeName }, ... }), the else branch parses fields directly off the inner document JsValue using IdentifierWasm::try_from_options(&document_js, "id") etc. Resulting errors surface as 'id' is required / 'ownerId' is required against an opaque object, with no indication that the missing field is options.document.id rather than options.id. Pure diagnostics — no correctness impact — but the create/replace paths route through DocumentWasm and don't have this asymmetry. Wrapping these errors with a document. path prefix would keep the JS error surface symmetric with the documented options shape.
💡 Suggested change
| } else { | |
| ( | |
| IdentifierWasm::try_from_options(&document_js, "id")?.into(), | |
| IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(), | |
| IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(), | |
| try_from_options_with(&document_js, "documentTypeName", |v| { | |
| try_to_string(v, "documentTypeName") | |
| })?, | |
| ) | |
| }; | |
| } else { | |
| ( | |
| IdentifierWasm::try_from_options(&document_js, "id") | |
| .map_err(|err| WasmSdkError::invalid_argument(format!("document.id: {err}")))? | |
| .into(), | |
| IdentifierWasm::try_from_options(&document_js, "ownerId") | |
| .map_err(|err| WasmSdkError::invalid_argument(format!("document.ownerId: {err}")))? | |
| .into(), | |
| IdentifierWasm::try_from_options(&document_js, "dataContractId") | |
| .map_err(|err| WasmSdkError::invalid_argument(format!("document.dataContractId: {err}")))? | |
| .into(), | |
| try_from_options_with(&document_js, "documentTypeName", |v| { | |
| try_to_string(v, "documentTypeName") | |
| }) | |
| .map_err(|err| { | |
| WasmSdkError::invalid_argument(format!("document.documentTypeName: {err}")) | |
| })?, | |
| ) | |
| }; |
source: ['claude-general', 'claude-rust-quality', 'claude-ffi-engineer', 'codex-ffi-engineer']
| pub async fn refresh_identity_nonce(&self, identity_id: &Identifier) { | ||
| self.nonce_cache.refresh(identity_id).await; | ||
| } | ||
|
|
||
| /// Conditionally roll back a previously-bumped identity-contract nonce | ||
| /// after a **local** (pre-broadcast) failure. | ||
| /// | ||
| /// Call this only when the caller is certain the nonce was never observed | ||
| /// by the network — e.g. when build/sign or local structure validation | ||
| /// fails right after a successful | ||
| /// [`get_identity_contract_nonce`](Self::get_identity_contract_nonce) with | ||
| /// `bump_first = true`. The rollback is conditional: it only adjusts the | ||
| /// cache entry if its current nonce still equals `allocated_nonce`, | ||
| /// avoiding clobbering concurrent newer allocations. A missing or | ||
| /// already-advanced entry is left untouched. | ||
| /// | ||
| /// For broadcast failures keep using | ||
| /// [`refresh_identity_nonce`](Self::refresh_identity_nonce), which | ||
| /// preserves the cached (bumped) value so the cache cannot regress past | ||
| /// a nonce the network may have accepted. | ||
| pub async fn rollback_identity_contract_nonce( | ||
| &self, | ||
| identity_id: Identifier, | ||
| contract_id: Identifier, | ||
| allocated_nonce: IdentityNonce, | ||
| ) { | ||
| self.nonce_cache | ||
| .rollback_identity_contract_nonce(identity_id, contract_id, allocated_nonce) | ||
| .await; | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: rollback_identity_contract_nonce takes Identifier by value while refresh_identity_nonce takes &Identifier
Verified at sdk.rs:486 and 506-511. The two halves of the same recovery policy are documented together but take their Identifier arguments asymmetrically (by &Identifier vs by Identifier). Identifier is Copy, so this is not a soundness or perf concern, but it forces callers in both Rust and rs-sdk-ffi to remember which API is which when wrapping or refactoring these. Aligning the signatures (either reference for both, or value for both) removes a small recurring footgun. Low priority; mention only.
source: ['claude-rust-quality']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified at 70c884b. The PR resolves the prior blocking nonce-leak / silent-id-rewrite issues. One new blocking regression remains: tightening Document::put_to_platform to reject None entropy on the create branch breaks an in-tree native caller (rs-platform-wallet profile creation). Remaining items are non-blocking parity, hygiene, and diagnostics findings on the FFI delete path, the still-permissive public dispatcher, and the JS->wasm boundary.
Reviewed commit: 70c884b
🔴 1 blocking | 🟡 4 suggestion(s) | 💬 4 nitpick(s)
1 additional finding
💬 nitpick: tokenPaymentInfo crosses the JS->wasm boundary via unchecked_into without an is_object check
packages/wasm-sdk/src/state_transitions/document.rs (lines 125-146)
Verified at lines 125-146. try_from_options_optional_token_payment_info rejects only null and undefined, then unchecked_into::<TokenPaymentInfoOptionsJs>() force-casts whatever JS value was supplied before handing it to TokenPaymentInfoWasm::constructor. Because the constructor re-reads via Reflect, callers passing tokenPaymentInfo: 42 or tokenPaymentInfo: "x" get cascading per-field '<field>' is required diagnostics rather than a clear tokenPaymentInfo must be an object. No memory-safety risk — wasm-bindgen's unchecked_into is safe on the Rust side — but the boundary contract is implicit. The new prepare* APIs widen the surface that flows through this helper, making a clean error worth the small additional check.
💡 Suggested change
if token_payment_info_value.is_null() || token_payment_info_value.is_undefined() {
return Ok(None);
}
if !token_payment_info_value.is_object() {
return Err(WasmSdkError::invalid_argument(
"tokenPaymentInfo must be an object",
));
}
let token_payment_info = TokenPaymentInfoWasm::constructor(
token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(),
)
.map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?;
Ok(Some(token_payment_info.into()))
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [BLOCKING] lines 355-393: PutDocument::put_to_platform create path now rejects an in-tree native caller (rs-platform-wallet profile creation)
Verified at put_document.rs:355-393. Pre-this-commit, the create branch forwarded `document_state_transition_entropy` to `build_signed_document_create_or_replace_transition`, which silently regenerated entropy and rewrote `document.id` when `None` was supplied. After this commit, the dispatcher routes `revision.is_none() || revision == Some(INITIAL_REVISION)` through the strict create wrapper and hard-errors with `Error::Generic("InvalidArgument: document_state_transition_entropy is required ...")` when entropy is `None`.
This is the right safety improvement, but the PR scopes the new revision/entropy guards to the wasm-sdk prepare paths and does not flag the rs-sdk trait-behavior change. There is at least one in-repo native caller that relies on the old auto-entropy behavior: `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs:353-364` calls `stub_document.put_to_platform_and_wait_for_response(.., document_type, None /* entropy */, signing_key, None, &SignerRef(signer), None)` on a freshly-built `DocumentV0 { revision: None, id: Identifier::from([0u8; 32]), .. }`. Under HEAD, this returns `InvalidArgument` before broadcast and DashPay profile creation regresses at runtime.
Either (a) restore the auto-id-generation fallback for the trait path (and keep the strict requirement only in the new strict-create wrapper / wasm prepare APIs), (b) update `rs-platform-wallet`'s profile create path to derive entropy + document id locally and pass `Some(entropy)` so the strict contract is satisfied, or (c) introduce the strict requirement as a new API and leave the existing trait method backward-compatible. Whichever route is chosen, this also needs a release note: any third-party native rs-sdk consumer of `Document::put_to_platform{,_and_wait_for_response}` that omitted entropy will hit the same regression on upgrade.
- [SUGGESTION] lines 232-243: Strict create helper does not locally enforce document.id matches the supplied entropy
Verified at put_document.rs:222-244. `build_signed_document_create_transition` documents that the supplied entropy "must match the entropy used to derive the document's `id`", but the implementation only enforces the revision boundary (`ensure_revision_for_create`) and delegates to the generic builder. `build_and_sign_create_or_replace_after_nonce` then forwards both `document.id()` and the entropy into DPP without checking they agree.
When they disagree, the invariant is only caught later in Drive-ABCI advanced-structure validation, after this prepare/one-shot API has already allocated a nonce and signed. Because this PR is explicitly introducing pre-broadcast builders for idempotent retries, this remaining late failure undermines the contract: callers should get a local `InvalidArgument` before any nonce allocation or signing when `document.id != Document::generate_document_id_v0(contract_id, owner_id, document_type_name, entropy)`. Add the check at the top of `build_signed_document_create_transition` (right after `ensure_revision_for_create`).
- [SUGGESTION] lines 72-199: Public dispatcher still silently regenerates entropy and rewrites document.id when entropy=None on the create branch
Verified at put_document.rs:72-91 and 154-200. The strict `build_signed_document_create_transition` (lines 222-244) now takes `[u8; 32]` and `PutDocument::put_to_platform` rejects `None` entropy, closing the ambient hazard for the canonical paths. But `build_signed_document_create_or_replace_transition` is still `pub`, still accepts `Option<[u8; 32]>`, and on the create branch still feeds `None` into `resolve_document_create_entropy`, which generates fresh entropy via `StdRng::from_entropy()` and rewrites `document.id` via `Document::generate_document_id_v0`. No in-tree caller currently trips this branch (wasm-sdk and the new PutDocument dispatcher both supply `Some(...)` or hard-error), but a future native rs-sdk or rs-sdk-ffi consumer reaching for the dispatcher directly with their own pre-derived id will silently receive a transition that mutated their document. Either tighten the dispatcher's create-branch entropy parameter to `[u8; 32]` (split into create vs replace dispatchers, or require entropy on a strict create-only signature), or surface the regenerated entropy/id in the return so the rewrite cannot be silent. The replace branch is unaffected.
In `packages/rs-sdk-ffi/src/document/delete.rs`:
- [SUGGESTION] lines 174-218: FFI delete prepare path skips ensure_valid_state_transition_structure that rs-sdk and wasm-sdk both run
Verified at delete.rs:174-218. `dash_sdk_document_delete` allocates the contract nonce, calls `sign_with_nonce`, and goes straight to `bincode::encode_to_vec` without invoking `ensure_valid_state_transition_structure(&state_transition, sdk.version())`. Both peer paths now run the validator between sign and the next step:
- rs-sdk `Sdk::document_delete` (packages/rs-sdk/src/platform/documents/transitions/delete.rs:427-433), with rollback on failure.
- wasm-sdk `prepareDocumentDelete` (packages/wasm-sdk/src/state_transitions/document.rs:942-949), with rollback on failure.
Today this is masked because batch transition structure validation in DPP returns `UnsupportedFeatureError` and is filtered out, but the moment that validator does real work, Swift/C consumers will silently emit invalid signed bytes for caching/rebroadcast while wasm and native Rust consumers reject them locally. Add the same call here (with `rollback_identity_contract_nonce` on `Err`) to close the parity gap and match the function header's documented pre-broadcast semantics.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 387-433: Sdk::document_delete's new allocate-then-rollback contract has no direct Rust test
This commit adds the same allocate->sign_with_nonce->validate->rollback-on-error pattern to `Sdk::document_delete` (lines 402-433) that was added to the strict create helper. The strict create helper got a new `#[cfg(test)]` test `build_signed_document_create_rolls_back_nonce_on_signing_failure` (put_document.rs:623-690) using `AlwaysFailingSigner` + `expect_fetch::<IdentityContractNonceFetcher, _>` — that pattern is directly applicable here. Failure-path coverage for `Sdk::document_delete`'s new rollback lives only in TS via the wasm-sdk prepare suite, which exercises a separate code shape. A future refactor that drops one of the rollback arms (sign-failure at lines 416-424 or validation-failure at lines 427-433), or that swaps `rollback_identity_contract_nonce` for `refresh_identity_nonce`, would slip past the unit-test layer. Mirror the create-side test against `Sdk::document_delete` to pin both branches.
| let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) { | ||
| // Create path: entropy is required. Reject `None` *before* we | ||
| // allocate a nonce so we never advance the cache for an | ||
| // attempt we know cannot succeed. We deliberately do NOT | ||
| // silently fall back to regenerating the document id — the | ||
| // caller's id must match the entropy they supplied. | ||
| let entropy = document_state_transition_entropy.ok_or_else(|| { | ||
| Error::Generic( | ||
| "InvalidArgument: document_state_transition_entropy is required \ | ||
| when calling put_to_platform on the create path; supply the \ | ||
| 32-byte entropy that was used to derive the document id" | ||
| .to_string(), | ||
| ) | ||
| })?; | ||
| build_signed_document_create_transition( | ||
| sdk, | ||
| self, | ||
| &document_type, | ||
| entropy, | ||
| &identity_public_key, | ||
| new_identity_contract_nonce, | ||
| settings.user_fee_increase.unwrap_or_default(), | ||
| token_payment_info, | ||
| signer, | ||
| sdk.version(), | ||
| settings.state_transition_creation_options, | ||
| settings, | ||
| ) | ||
| .await? | ||
| } else { | ||
| let (document, document_state_transition_entropy) = document_state_transition_entropy | ||
| .map(|entropy| (self.clone(), entropy)) | ||
| .unwrap_or_else(|| { | ||
| let mut rng = StdRng::from_entropy(); | ||
| let mut document = self.clone(); | ||
| let entropy = rng.gen::<[u8; 32]>(); | ||
| document.set_id(Document::generate_document_id_v0( | ||
| &document_type.data_contract_id(), | ||
| &document.owner_id(), | ||
| document_type.name(), | ||
| entropy.as_slice(), | ||
| )); | ||
| (document, entropy) | ||
| }); | ||
| BatchTransition::new_document_creation_transition_from_document( | ||
| document, | ||
| document_type.as_ref(), | ||
| document_state_transition_entropy, | ||
| // Replace path: entropy is unused; the strict helper enforces | ||
| // `revision > INITIAL_REVISION`. | ||
| build_signed_document_replace_transition( | ||
| sdk, | ||
| self, | ||
| &document_type, | ||
| &identity_public_key, | ||
| new_identity_contract_nonce, | ||
| settings.user_fee_increase.unwrap_or_default(), | ||
| token_payment_info, | ||
| signer, | ||
| sdk.version(), | ||
| settings.state_transition_creation_options, | ||
| settings, | ||
| ) | ||
| .await? | ||
| }; |
There was a problem hiding this comment.
🔴 Blocking: PutDocument::put_to_platform create path now rejects an in-tree native caller (rs-platform-wallet profile creation)
Verified at put_document.rs:355-393. Pre-this-commit, the create branch forwarded document_state_transition_entropy to build_signed_document_create_or_replace_transition, which silently regenerated entropy and rewrote document.id when None was supplied. After this commit, the dispatcher routes revision.is_none() || revision == Some(INITIAL_REVISION) through the strict create wrapper and hard-errors with Error::Generic("InvalidArgument: document_state_transition_entropy is required ...") when entropy is None.
This is the right safety improvement, but the PR scopes the new revision/entropy guards to the wasm-sdk prepare paths and does not flag the rs-sdk trait-behavior change. There is at least one in-repo native caller that relies on the old auto-entropy behavior: packages/rs-platform-wallet/src/wallet/identity/network/profile.rs:353-364 calls stub_document.put_to_platform_and_wait_for_response(.., document_type, None /* entropy */, signing_key, None, &SignerRef(signer), None) on a freshly-built DocumentV0 { revision: None, id: Identifier::from([0u8; 32]), .. }. Under HEAD, this returns InvalidArgument before broadcast and DashPay profile creation regresses at runtime.
Either (a) restore the auto-id-generation fallback for the trait path (and keep the strict requirement only in the new strict-create wrapper / wasm prepare APIs), (b) update rs-platform-wallet's profile create path to derive entropy + document id locally and pass Some(entropy) so the strict contract is satisfied, or (c) introduce the strict requirement as a new API and leave the existing trait method backward-compatible. Whichever route is chosen, this also needs a release note: any third-party native rs-sdk consumer of Document::put_to_platform{,_and_wait_for_response} that omitted entropy will hit the same regression on upgrade.
source: ['codex', 'claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [BLOCKING] lines 355-393: PutDocument::put_to_platform create path now rejects an in-tree native caller (rs-platform-wallet profile creation)
Verified at put_document.rs:355-393. Pre-this-commit, the create branch forwarded `document_state_transition_entropy` to `build_signed_document_create_or_replace_transition`, which silently regenerated entropy and rewrote `document.id` when `None` was supplied. After this commit, the dispatcher routes `revision.is_none() || revision == Some(INITIAL_REVISION)` through the strict create wrapper and hard-errors with `Error::Generic("InvalidArgument: document_state_transition_entropy is required ...")` when entropy is `None`.
This is the right safety improvement, but the PR scopes the new revision/entropy guards to the wasm-sdk prepare paths and does not flag the rs-sdk trait-behavior change. There is at least one in-repo native caller that relies on the old auto-entropy behavior: `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs:353-364` calls `stub_document.put_to_platform_and_wait_for_response(.., document_type, None /* entropy */, signing_key, None, &SignerRef(signer), None)` on a freshly-built `DocumentV0 { revision: None, id: Identifier::from([0u8; 32]), .. }`. Under HEAD, this returns `InvalidArgument` before broadcast and DashPay profile creation regresses at runtime.
Either (a) restore the auto-id-generation fallback for the trait path (and keep the strict requirement only in the new strict-create wrapper / wasm prepare APIs), (b) update `rs-platform-wallet`'s profile create path to derive entropy + document id locally and pass `Some(entropy)` so the strict contract is satisfied, or (c) introduce the strict requirement as a new API and leave the existing trait method backward-compatible. Whichever route is chosen, this also needs a release note: any third-party native rs-sdk consumer of `Document::put_to_platform{,_and_wait_for_response}` that omitted entropy will hit the same regression on upgrade.
| let state_transition = match builder | ||
| .sign_with_nonce( | ||
| identity_contract_nonce, | ||
| identity_public_key, | ||
| signer, | ||
| wrapper.sdk.version(), | ||
| ) | ||
| .await | ||
| .map_err(|e| { | ||
| FFIError::InternalError(format!("Failed to create delete transition: {}", e)) | ||
| })?; | ||
| { | ||
| Ok(transition) => transition, | ||
| Err(e) => { | ||
| wrapper | ||
| .sdk | ||
| .rollback_identity_contract_nonce( | ||
| owner_identifier, | ||
| contract_id_for_nonce, | ||
| identity_contract_nonce, | ||
| ) | ||
| .await; | ||
| return Err(FFIError::InternalError(format!( | ||
| "Failed to create delete transition: {}", | ||
| e | ||
| ))); | ||
| } | ||
| }; | ||
|
|
||
| // Serialize the state transition with bincode | ||
| let config = bincode::config::standard(); | ||
| let serialized = bincode::encode_to_vec(&state_transition, config).map_err(|e| { | ||
| FFIError::InternalError(format!("Failed to serialize state transition: {}", e)) | ||
| })?; | ||
| let serialized = match bincode::encode_to_vec(&state_transition, config) { | ||
| Ok(bytes) => bytes, | ||
| Err(e) => { | ||
| wrapper | ||
| .sdk | ||
| .rollback_identity_contract_nonce( | ||
| owner_identifier, | ||
| contract_id_for_nonce, | ||
| identity_contract_nonce, | ||
| ) | ||
| .await; | ||
| return Err(FFIError::InternalError(format!( | ||
| "Failed to serialize state transition: {}", | ||
| e | ||
| ))); | ||
| } | ||
| }; |
There was a problem hiding this comment.
🟡 Suggestion: FFI delete prepare path skips ensure_valid_state_transition_structure that rs-sdk and wasm-sdk both run
Verified at delete.rs:174-218. dash_sdk_document_delete allocates the contract nonce, calls sign_with_nonce, and goes straight to bincode::encode_to_vec without invoking ensure_valid_state_transition_structure(&state_transition, sdk.version()). Both peer paths now run the validator between sign and the next step:
- rs-sdk
Sdk::document_delete(packages/rs-sdk/src/platform/documents/transitions/delete.rs:427-433), with rollback on failure. - wasm-sdk
prepareDocumentDelete(packages/wasm-sdk/src/state_transitions/document.rs:942-949), with rollback on failure.
Today this is masked because batch transition structure validation in DPP returns UnsupportedFeatureError and is filtered out, but the moment that validator does real work, Swift/C consumers will silently emit invalid signed bytes for caching/rebroadcast while wasm and native Rust consumers reject them locally. Add the same call here (with rollback_identity_contract_nonce on Err) to close the parity gap and match the function header's documented pre-broadcast semantics.
source: ['claude', 'codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk-ffi/src/document/delete.rs`:
- [SUGGESTION] lines 174-218: FFI delete prepare path skips ensure_valid_state_transition_structure that rs-sdk and wasm-sdk both run
Verified at delete.rs:174-218. `dash_sdk_document_delete` allocates the contract nonce, calls `sign_with_nonce`, and goes straight to `bincode::encode_to_vec` without invoking `ensure_valid_state_transition_structure(&state_transition, sdk.version())`. Both peer paths now run the validator between sign and the next step:
- rs-sdk `Sdk::document_delete` (packages/rs-sdk/src/platform/documents/transitions/delete.rs:427-433), with rollback on failure.
- wasm-sdk `prepareDocumentDelete` (packages/wasm-sdk/src/state_transitions/document.rs:942-949), with rollback on failure.
Today this is masked because batch transition structure validation in DPP returns `UnsupportedFeatureError` and is filtered out, but the moment that validator does real work, Swift/C consumers will silently emit invalid signed bytes for caching/rebroadcast while wasm and native Rust consumers reject them locally. Add the same call here (with `rollback_identity_contract_nonce` on `Err`) to close the parity gap and match the function header's documented pre-broadcast semantics.
| ensure_revision_for_create(document.revision())?; | ||
| build_signed_document_create_or_replace_transition( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| Some(document_state_transition_entropy), | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| ) | ||
| .await |
There was a problem hiding this comment.
🟡 Suggestion: Strict create helper does not locally enforce document.id matches the supplied entropy
Verified at put_document.rs:222-244. build_signed_document_create_transition documents that the supplied entropy "must match the entropy used to derive the document's id", but the implementation only enforces the revision boundary (ensure_revision_for_create) and delegates to the generic builder. build_and_sign_create_or_replace_after_nonce then forwards both document.id() and the entropy into DPP without checking they agree.
When they disagree, the invariant is only caught later in Drive-ABCI advanced-structure validation, after this prepare/one-shot API has already allocated a nonce and signed. Because this PR is explicitly introducing pre-broadcast builders for idempotent retries, this remaining late failure undermines the contract: callers should get a local InvalidArgument before any nonce allocation or signing when document.id != Document::generate_document_id_v0(contract_id, owner_id, document_type_name, entropy). Add the check at the top of build_signed_document_create_transition (right after ensure_revision_for_create).
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 232-243: Strict create helper does not locally enforce document.id matches the supplied entropy
Verified at put_document.rs:222-244. `build_signed_document_create_transition` documents that the supplied entropy "must match the entropy used to derive the document's `id`", but the implementation only enforces the revision boundary (`ensure_revision_for_create`) and delegates to the generic builder. `build_and_sign_create_or_replace_after_nonce` then forwards both `document.id()` and the entropy into DPP without checking they agree.
When they disagree, the invariant is only caught later in Drive-ABCI advanced-structure validation, after this prepare/one-shot API has already allocated a nonce and signed. Because this PR is explicitly introducing pre-broadcast builders for idempotent retries, this remaining late failure undermines the contract: callers should get a local `InvalidArgument` before any nonce allocation or signing when `document.id != Document::generate_document_id_v0(contract_id, owner_id, document_type_name, entropy)`. Add the check at the top of `build_signed_document_create_transition` (right after `ensure_revision_for_create`).
| @@ -46,6 +121,218 @@ pub trait PutDocument<S: Signer<IdentityPublicKey>>: Waitable { | |||
| ) -> Result<Document, Error>; | |||
| } | |||
|
|
|||
| /// Build, sign, and structurally validate a document create-or-replace | |||
| /// [`StateTransition`] without broadcasting it. | |||
| /// | |||
| /// This is the pre-broadcast half of [`PutDocument::put_to_platform`]: it | |||
| /// allocates a fresh identity-contract nonce, picks the create-vs-replace | |||
| /// branch based on the document's revision, fills in entropy when missing, | |||
| /// applies `user_fee_increase` / `token_payment_info` / | |||
| /// `state_transition_creation_options` from `settings`, signs the transition, | |||
| /// and runs structure validation. The caller decides whether (and how) to | |||
| /// broadcast the returned, signed transition. | |||
| /// | |||
| /// # Revision validation | |||
| /// | |||
| /// The dispatch is driven by the document revision and rejects only the | |||
| /// always-invalid `Some(0)` case. Use | |||
| /// [`build_signed_document_create_transition`] or | |||
| /// [`build_signed_document_replace_transition`] for fail-fast validation that | |||
| /// also enforces caller intent (mismatched create/replace revisions error | |||
| /// before any nonce allocation). | |||
| /// | |||
| /// # Nonce handling on local errors | |||
| /// | |||
| /// On any **pre-broadcast** failure (build, sign, or local structure | |||
| /// validation) this helper conditionally rolls back the bumped | |||
| /// identity-contract nonce via | |||
| /// [`Sdk::rollback_identity_contract_nonce`], so the local cache does not | |||
| /// advance past a nonce the network never observed. The rollback only adjusts | |||
| /// the cache entry if it still equals the nonce allocated by this attempt, so | |||
| /// concurrent allocations are not clobbered. | |||
| #[allow(clippy::too_many_arguments)] | |||
| pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>( | |||
| sdk: &Sdk, | |||
| document: &Document, | |||
| document_type: &DocumentType, | |||
| document_state_transition_entropy: Option<[u8; 32]>, | |||
| identity_public_key: &IdentityPublicKey, | |||
| token_payment_info: Option<TokenPaymentInfo>, | |||
| signer: &S, | |||
| settings: Option<PutSettings>, | |||
| ) -> Result<StateTransition, Error> { | |||
| // Reject the always-invalid `Some(0)` revision before allocating any | |||
| // nonce. Strict create/replace intent validation is the job of the | |||
| // dedicated helpers below. | |||
| ensure_revision_nonzero(document.revision())?; | |||
|
|
|||
| let owner_id = document.owner_id(); | |||
| let contract_id = document_type.data_contract_id(); | |||
| let new_identity_contract_nonce = sdk | |||
| .get_identity_contract_nonce(owner_id, contract_id, true, settings) | |||
| .await?; | |||
|
|
|||
| let result = build_and_sign_create_or_replace_after_nonce( | |||
| sdk, | |||
| document, | |||
| document_type, | |||
| document_state_transition_entropy, | |||
| identity_public_key, | |||
| token_payment_info, | |||
| signer, | |||
| settings, | |||
| new_identity_contract_nonce, | |||
| ) | |||
| .await; | |||
|
|
|||
| match result { | |||
| Ok(transition) => Ok(transition), | |||
| Err(err) => { | |||
| sdk.rollback_identity_contract_nonce( | |||
| owner_id, | |||
| contract_id, | |||
| new_identity_contract_nonce, | |||
| ) | |||
| .await; | |||
| Err(err) | |||
| } | |||
| } | |||
There was a problem hiding this comment.
🟡 Suggestion: Public dispatcher still silently regenerates entropy and rewrites document.id when entropy=None on the create branch
Verified at put_document.rs:72-91 and 154-200. The strict build_signed_document_create_transition (lines 222-244) now takes [u8; 32] and PutDocument::put_to_platform rejects None entropy, closing the ambient hazard for the canonical paths. But build_signed_document_create_or_replace_transition is still pub, still accepts Option<[u8; 32]>, and on the create branch still feeds None into resolve_document_create_entropy, which generates fresh entropy via StdRng::from_entropy() and rewrites document.id via Document::generate_document_id_v0. No in-tree caller currently trips this branch (wasm-sdk and the new PutDocument dispatcher both supply Some(...) or hard-error), but a future native rs-sdk or rs-sdk-ffi consumer reaching for the dispatcher directly with their own pre-derived id will silently receive a transition that mutated their document. Either tighten the dispatcher's create-branch entropy parameter to [u8; 32] (split into create vs replace dispatchers, or require entropy on a strict create-only signature), or surface the regenerated entropy/id in the return so the rewrite cannot be silent. The replace branch is unaffected.
source: ['claude', 'codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 72-199: Public dispatcher still silently regenerates entropy and rewrites document.id when entropy=None on the create branch
Verified at put_document.rs:72-91 and 154-200. The strict `build_signed_document_create_transition` (lines 222-244) now takes `[u8; 32]` and `PutDocument::put_to_platform` rejects `None` entropy, closing the ambient hazard for the canonical paths. But `build_signed_document_create_or_replace_transition` is still `pub`, still accepts `Option<[u8; 32]>`, and on the create branch still feeds `None` into `resolve_document_create_entropy`, which generates fresh entropy via `StdRng::from_entropy()` and rewrites `document.id` via `Document::generate_document_id_v0`. No in-tree caller currently trips this branch (wasm-sdk and the new PutDocument dispatcher both supply `Some(...)` or hard-error), but a future native rs-sdk or rs-sdk-ffi consumer reaching for the dispatcher directly with their own pre-derived id will silently receive a transition that mutated their document. Either tighten the dispatcher's create-branch entropy parameter to `[u8; 32]` (split into create vs replace dispatchers, or require entropy on a strict create-only signature), or surface the regenerated entropy/id in the return so the rewrite cannot be silent. The replace branch is unaffected.
| let platform_version = self.version(); | ||
|
|
||
| let put_settings = delete_document_transition_builder.settings; | ||
| let owner_id = delete_document_transition_builder.owner_id; | ||
| let contract_id = delete_document_transition_builder.data_contract.id(); | ||
|
|
||
| let state_transition = delete_document_transition_builder | ||
| .sign(self, signing_key, signer, platform_version) | ||
| // Allocate the identity-contract nonce explicitly so we can roll it | ||
| // back on pre-broadcast failures without leaving the local cache | ||
| // advanced past a nonce the network never observed. | ||
| let identity_contract_nonce = self | ||
| .get_identity_contract_nonce(owner_id, contract_id, true, put_settings) | ||
| .await?; | ||
|
|
||
| let state_transition = match delete_document_transition_builder | ||
| .sign_with_nonce( | ||
| identity_contract_nonce, | ||
| signing_key, | ||
| signer, | ||
| platform_version, | ||
| ) | ||
| .await | ||
| { | ||
| Ok(transition) => transition, | ||
| Err(err) => { | ||
| self.rollback_identity_contract_nonce( | ||
| owner_id, | ||
| contract_id, | ||
| identity_contract_nonce, | ||
| ) | ||
| .await; | ||
| return Err(err); | ||
| } | ||
| }; | ||
|
|
||
| if let Err(err) = | ||
| ensure_valid_state_transition_structure(&state_transition, platform_version) | ||
| { | ||
| self.rollback_identity_contract_nonce(owner_id, contract_id, identity_contract_nonce) | ||
| .await; | ||
| return Err(err); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Sdk::document_delete's new allocate-then-rollback contract has no direct Rust test
This commit adds the same allocate->sign_with_nonce->validate->rollback-on-error pattern to Sdk::document_delete (lines 402-433) that was added to the strict create helper. The strict create helper got a new #[cfg(test)] test build_signed_document_create_rolls_back_nonce_on_signing_failure (put_document.rs:623-690) using AlwaysFailingSigner + expect_fetch::<IdentityContractNonceFetcher, _> — that pattern is directly applicable here. Failure-path coverage for Sdk::document_delete's new rollback lives only in TS via the wasm-sdk prepare suite, which exercises a separate code shape. A future refactor that drops one of the rollback arms (sign-failure at lines 416-424 or validation-failure at lines 427-433), or that swaps rollback_identity_contract_nonce for refresh_identity_nonce, would slip past the unit-test layer. Mirror the create-side test against Sdk::document_delete to pin both branches.
source: ['claude', 'codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 387-433: Sdk::document_delete's new allocate-then-rollback contract has no direct Rust test
This commit adds the same allocate->sign_with_nonce->validate->rollback-on-error pattern to `Sdk::document_delete` (lines 402-433) that was added to the strict create helper. The strict create helper got a new `#[cfg(test)]` test `build_signed_document_create_rolls_back_nonce_on_signing_failure` (put_document.rs:623-690) using `AlwaysFailingSigner` + `expect_fetch::<IdentityContractNonceFetcher, _>` — that pattern is directly applicable here. Failure-path coverage for `Sdk::document_delete`'s new rollback lives only in TS via the wasm-sdk prepare suite, which exercises a separate code shape. A future refactor that drops one of the rollback arms (sign-failure at lines 416-424 or validation-failure at lines 427-433), or that swaps `rollback_identity_contract_nonce` for `refresh_identity_nonce`, would slip past the unit-test layer. Mirror the create-side test against `Sdk::document_delete` to pin both branches.
| // Pre-allocate the identity-contract nonce so that any pre-broadcast | ||
| // failure (sign or local structure validation) can be rolled back via | ||
| // rs-sdk's `rollback_identity_contract_nonce`. The rollback is | ||
| // conditional: it only adjusts the cache entry if it still equals the | ||
| // nonce allocated here, so it does not clobber concurrent allocations. | ||
| let allocated_nonce = self | ||
| .inner_sdk() | ||
| .get_identity_contract_nonce(owner_id, contract_id, true, settings) | ||
| .await?; | ||
|
|
||
| let state_transition = match builder | ||
| .sign_with_nonce( | ||
| allocated_nonce, | ||
| &identity_key, | ||
| &signer, | ||
| self.inner_sdk().version(), | ||
| ) | ||
| .await | ||
| { | ||
| Ok(st) => st, | ||
| Err(err) => { | ||
| self.inner_sdk() | ||
| .rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) | ||
| .await; | ||
| return Err(err.into()); | ||
| } | ||
| }; | ||
|
|
||
| // Validate structure before handing the ST back, mirroring rs-sdk's | ||
| // pre-broadcast check. For document Batch transitions this currently | ||
| // ends up as a no-op because DPP returns UnsupportedFeatureError until | ||
| // that structure validation is implemented there. | ||
| if let Err(err) = | ||
| ensure_valid_state_transition_structure(&state_transition, self.inner_sdk().version()) | ||
| { | ||
| self.inner_sdk() | ||
| .rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) | ||
| .await; | ||
| return Err(err.into()); | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Allocate-then-rollback policy for document delete is now duplicated across three crates
The same allocate->sign_with_nonce->(optional validate)->conditional-rollback dance now lives in three places: rs-sdk Sdk::document_delete (packages/rs-sdk/src/platform/documents/transitions/delete.rs:402-433), rs-sdk-ffi dash_sdk_document_delete (packages/rs-sdk-ffi/src/document/delete.rs:157-218), and wasm-sdk prepare_document_delete (this file, lines 910-949). The three differ in small ways (the FFI variant skips ensure_valid_state_transition_structure and adds a rollback for bincode encoding; the others run the validator with rollback). Hygiene-only — a future change to the recovery policy must touch three differently-shaped sites, and a forget-to-rollback regression on any branch will surface only as silent local-cache drift in that consumer. Exposing a build_signed_document_delete_transition in rs-sdk that mirrors the create/replace helpers would absorb the orchestration in one place.
source: ['claude', 'codex']
| } else { | ||
| ( | ||
| IdentifierWasm::try_from_options(&document_js, "id")?.into(), | ||
| IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(), | ||
| IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(), | ||
| try_from_options_with(&document_js, "documentTypeName", |v| { | ||
| try_to_string(v, "documentTypeName") | ||
| })?, | ||
| ) | ||
| }; |
There was a problem hiding this comment.
💬 Nitpick: prepareDocumentDelete plain-object errors lose the document. path prefix
Verified at document.rs:864-873. When the JS caller uses the documented plain-object form ({ document: { id, ownerId, dataContractId, documentTypeName }, ... }), the else branch parses fields directly off the inner document JsValue using IdentifierWasm::try_from_options(&document_js, "id") etc. Resulting failures surface as 'id' is required / 'ownerId' is required against an opaque object, with no indication that the missing field is options.document.id rather than options.id. Pure diagnostics — no correctness impact — but the create/replace paths route through DocumentWasm and don't have this asymmetry. Wrapping these errors with a document. path prefix keeps the JS error surface symmetric with the documented options shape. The same diagnostic gap exists on documentDelete at lines 498-506.
💡 Suggested change
| } else { | |
| ( | |
| IdentifierWasm::try_from_options(&document_js, "id")?.into(), | |
| IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(), | |
| IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(), | |
| try_from_options_with(&document_js, "documentTypeName", |v| { | |
| try_to_string(v, "documentTypeName") | |
| })?, | |
| ) | |
| }; | |
| } else { | |
| ( | |
| IdentifierWasm::try_from_options(&document_js, "id") | |
| .map_err(|err| WasmSdkError::invalid_argument(format!("document.id: {err}")))? | |
| .into(), | |
| IdentifierWasm::try_from_options(&document_js, "ownerId") | |
| .map_err(|err| WasmSdkError::invalid_argument(format!("document.ownerId: {err}")))? | |
| .into(), | |
| IdentifierWasm::try_from_options(&document_js, "dataContractId") | |
| .map_err(|err| WasmSdkError::invalid_argument(format!("document.dataContractId: {err}")))? | |
| .into(), | |
| try_from_options_with(&document_js, "documentTypeName", |v| { | |
| try_to_string(v, "documentTypeName") | |
| }) | |
| .map_err(|err| { | |
| WasmSdkError::invalid_argument(format!("document.documentTypeName: {err}")) | |
| })?, | |
| ) | |
| }; |
source: ['claude', 'codex']
| let builder = if let Some(s) = settings { | ||
| builder.with_settings(s) | ||
| } else { | ||
| builder | ||
| }; |
There was a problem hiding this comment.
💬 Nitpick: with_settings on the prepare-delete builder is dead state once the manual sign_with_nonce route is taken
Lines 904-908 stash settings on the builder via builder.with_settings(s), but the prepare path then calls sign_with_nonce (920-927) which in DocumentDeleteTransitionBuilder::sign_with_nonce (rs-sdk documents/transitions/delete.rs:209-253) only consults self.user_fee_increase, self.token_payment_info, and self.state_transition_creation_options — not self.settings. The settings are also passed independently into get_identity_contract_nonce at line 917 (the only place the builder's sign() method would have used them). No correctness impact today (the same value is consumed at line 917), but it's a foot-gun: a maintainer who later switches the prepare path to builder.sign(...) will silently double-apply the setting, and any audit assuming builder fields are load-bearing will misread the call site. Either drop the with_settings call here or extend sign_with_nonce to consume it for parity with sign().
source: ['claude']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified at f4416d8: no blocking issues. The PR cleanly introduces two-phase document prepare APIs in wasm-sdk plus a strict pre-broadcast pipeline + conditional identity-contract nonce rollback in rs-sdk. Findings center on a public→private API break for build_signed_document_create_or_replace_transition, an order-dependent with_settings overwrite on the delete builder, and several architectural/polish nits (duplicated prepare+rollback recipe across rs-sdk/wasm-sdk/rs-sdk-ffi, an unreachable allocated_nonce == 0 guard, a redundant inner revision check, and a user-facing revision=0 error that bounces callers between APIs). 3 lower-value findings dropped.
Reviewed commit: f4416d8
🟡 5 suggestion(s) | 💬 5 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 223-224: `build_signed_document_create_or_replace_transition` was demoted from `pub` to private — source-breaking for native callers
Git history confirms this commit changed `pub async fn build_signed_document_create_or_replace_transition` to a private `async fn`. The enclosing `platform::transition::put_document` module is still `pub`, so this is a source-breaking change for downstream Rust consumers that were using the helper to prepare a signed document transition with revision-driven create/replace dispatch (and the legacy `entropy = None` fallback) without broadcasting. The new public replacements do not cover that exact surface: `build_signed_document_create_transition` requires the caller to commit to the create intent up front and supply matching entropy, `build_signed_document_replace_transition` is replace-only, and `PutDocument::put_to_platform` broadcasts immediately. If the visibility reduction is intentional, mention it in the PR notes and provide a migration path; otherwise restore `pub` and keep the strict helpers as the recommended entry points.
- [SUGGESTION] lines 434-495: Legacy `PutDocument::put_to_platform` now rejects `Some(entropy)` whose derived id ≠ `document.id` locally — undocumented compat change
Before this PR, `PutDocument::put_to_platform` with `document_state_transition_entropy = Some(e)` passed the entropy and the caller's `document.id` through to `BatchTransition::new_document_creation_transition_from_document` unchecked; mismatches were rejected server-side. After the refactor, `put_to_platform` routes the create path through `build_signed_document_create_transition` (line 464), whose `ensure_document_id_matches_entropy` guard rejects mismatches locally with `Error::Generic("InvalidArgument: document.id does not match generate_document_id_v0(...)")`. In-tree callers (`rs-platform-wallet` profile creation, etc.) all pass `None`, which still flows through `resolve_document_create_entropy` and is unaffected. But external rs-sdk consumers that built `document.id` themselves and supplied entropy explicitly will now hit a hard local error where the old code failed remotely. The new behavior is strictly more correct; it just isn't called out alongside the documented revision tightening. Add a line to the compat note in the module docstring (and the PR description) covering the entropy/id tightening for `Some(entropy)` callers.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 127-137: `with_settings` silently overwrites explicit `with_user_fee_increase` / `with_state_transition_creation_options` values
`with_settings` now unconditionally copies `settings.user_fee_increase` and `settings.state_transition_creation_options` into the dedicated builder fields whenever they are `Some`. This makes the final value order-dependent: `builder.with_user_fee_increase(42).with_settings(s_with_7)` ends at 7, while `builder.with_settings(s_with_7).with_user_fee_increase(42)` ends at 42. The FFI path at `packages/rs-sdk-ffi/src/document/delete.rs:146-152` actually depends on the second ordering. The precedence rule is undocumented in the builder API and easy to get wrong from a Rust consumer that calls `with_settings` last. Either (a) document the precedence on `with_settings`, or (b) have it only fill the dedicated fields when they are still `None`, so explicit setters always win regardless of call order.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 910-952: `prepare_document_delete` re-implements the nonce-alloc/sign/validate/rollback recipe that already lives in rs-sdk for create/replace
`prepare_document_create` and `prepare_document_replace` delegate to `build_signed_document_create_transition` / `build_signed_document_replace_transition`, which encapsulate (a) explicit nonce allocation, (b) `sign_with_nonce`, (c) `ensure_valid_state_transition_structure`, and (d) `rollback_identity_contract_nonce` on any pre-broadcast error. `prepare_document_delete` reimplements that recipe inline here, and `packages/rs-sdk-ffi/src/document/delete.rs:158` repeats it a third time. Three independent copies of a consensus-adjacent rollback contract is a drift hazard — if the rollback policy changes (e.g. adding tracing, a conditional refresh, or a structural retry) at least one site will be missed. Promote a `build_signed_document_delete_transition` helper next to the create/replace helpers in `packages/rs-sdk/src/platform/transition/put_document.rs` (or in the delete builder module) and funnel all three call sites through it.
In `packages/rs-sdk/src/sdk.rs`:
- [SUGGESTION] lines 502-514: Rollback API is identity-contract-only — note this or add the sibling for plain identity nonces before more transitions adopt prepare flows
`Sdk::rollback_identity_contract_nonce` is the new pre-broadcast rollback primitive, but the analogous identity-nonce path (`refresh_identity_nonce` / `nonce_cache.get_identity_nonce`) has no rollback. Document Batch transitions only consume identity-contract nonces today, so the asymmetry is currently fine. Once prepare-style APIs land for transitions that consume the plain identity nonce (identity update, credit transfer, contract create/update — all of which already share the same `get_or_fetch_nonce` machinery in `internal_cache/mod.rs:319`), they will silently leak local identity nonces on pre-broadcast failures unless the equivalent rollback exists. Either add `rollback_identity_nonce` now next to its sibling, or add a doc-comment noting that the rollback API is intentionally identity-contract-only until those transitions are converted.
| #[allow(clippy::too_many_arguments)] | ||
| async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>( |
There was a problem hiding this comment.
🟡 Suggestion: build_signed_document_create_or_replace_transition was demoted from pub to private — source-breaking for native callers
Git history confirms this commit changed pub async fn build_signed_document_create_or_replace_transition to a private async fn. The enclosing platform::transition::put_document module is still pub, so this is a source-breaking change for downstream Rust consumers that were using the helper to prepare a signed document transition with revision-driven create/replace dispatch (and the legacy entropy = None fallback) without broadcasting. The new public replacements do not cover that exact surface: build_signed_document_create_transition requires the caller to commit to the create intent up front and supply matching entropy, build_signed_document_replace_transition is replace-only, and PutDocument::put_to_platform broadcasts immediately. If the visibility reduction is intentional, mention it in the PR notes and provide a migration path; otherwise restore pub and keep the strict helpers as the recommended entry points.
💡 Suggested change
| #[allow(clippy::too_many_arguments)] | |
| async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>( | |
| #[allow(clippy::too_many_arguments)] | |
| pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>( |
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 223-224: `build_signed_document_create_or_replace_transition` was demoted from `pub` to private — source-breaking for native callers
Git history confirms this commit changed `pub async fn build_signed_document_create_or_replace_transition` to a private `async fn`. The enclosing `platform::transition::put_document` module is still `pub`, so this is a source-breaking change for downstream Rust consumers that were using the helper to prepare a signed document transition with revision-driven create/replace dispatch (and the legacy `entropy = None` fallback) without broadcasting. The new public replacements do not cover that exact surface: `build_signed_document_create_transition` requires the caller to commit to the create intent up front and supply matching entropy, `build_signed_document_replace_transition` is replace-only, and `PutDocument::put_to_platform` broadcasts immediately. If the visibility reduction is intentional, mention it in the PR notes and provide a migration path; otherwise restore `pub` and keep the strict helpers as the recommended entry points.
| pub fn with_settings(mut self, settings: PutSettings) -> Self { | ||
| if let Some(user_fee_increase) = settings.user_fee_increase { | ||
| self.user_fee_increase = Some(user_fee_increase); | ||
| } | ||
| if let Some(state_transition_creation_options) = settings.state_transition_creation_options | ||
| { | ||
| self.state_transition_creation_options = Some(state_transition_creation_options); | ||
| } | ||
| self.settings = Some(settings); | ||
| self | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: with_settings silently overwrites explicit with_user_fee_increase / with_state_transition_creation_options values
with_settings now unconditionally copies settings.user_fee_increase and settings.state_transition_creation_options into the dedicated builder fields whenever they are Some. This makes the final value order-dependent: builder.with_user_fee_increase(42).with_settings(s_with_7) ends at 7, while builder.with_settings(s_with_7).with_user_fee_increase(42) ends at 42. The FFI path at packages/rs-sdk-ffi/src/document/delete.rs:146-152 actually depends on the second ordering. The precedence rule is undocumented in the builder API and easy to get wrong from a Rust consumer that calls with_settings last. Either (a) document the precedence on with_settings, or (b) have it only fill the dedicated fields when they are still None, so explicit setters always win regardless of call order.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 127-137: `with_settings` silently overwrites explicit `with_user_fee_increase` / `with_state_transition_creation_options` values
`with_settings` now unconditionally copies `settings.user_fee_increase` and `settings.state_transition_creation_options` into the dedicated builder fields whenever they are `Some`. This makes the final value order-dependent: `builder.with_user_fee_increase(42).with_settings(s_with_7)` ends at 7, while `builder.with_settings(s_with_7).with_user_fee_increase(42)` ends at 42. The FFI path at `packages/rs-sdk-ffi/src/document/delete.rs:146-152` actually depends on the second ordering. The precedence rule is undocumented in the builder API and easy to get wrong from a Rust consumer that calls `with_settings` last. Either (a) document the precedence on `with_settings`, or (b) have it only fill the dedicated fields when they are still `None`, so explicit setters always win regardless of call order.
| // Pre-allocate the identity-contract nonce so that any pre-broadcast | ||
| // failure (sign or local structure validation) can be rolled back via | ||
| // rs-sdk's `rollback_identity_contract_nonce`. The rollback is | ||
| // conditional: it only adjusts the cache entry if it still equals the | ||
| // nonce allocated here, so it does not clobber concurrent allocations. | ||
| let allocated_nonce = self | ||
| .inner_sdk() | ||
| .get_identity_contract_nonce(owner_id, contract_id, true, settings) | ||
| .await?; | ||
|
|
||
| let state_transition = match builder | ||
| .sign_with_nonce( | ||
| allocated_nonce, | ||
| &identity_key, | ||
| &signer, | ||
| self.inner_sdk().version(), | ||
| ) | ||
| .await | ||
| { | ||
| Ok(st) => st, | ||
| Err(err) => { | ||
| self.inner_sdk() | ||
| .rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) | ||
| .await; | ||
| return Err(err.into()); | ||
| } | ||
| }; | ||
|
|
||
| // Validate structure before handing the ST back, mirroring rs-sdk's | ||
| // pre-broadcast check. For document Batch transitions this currently | ||
| // ends up as a no-op because DPP returns UnsupportedFeatureError until | ||
| // that structure validation is implemented there. | ||
| if let Err(err) = | ||
| ensure_valid_state_transition_structure(&state_transition, self.inner_sdk().version()) | ||
| { | ||
| self.inner_sdk() | ||
| .rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) | ||
| .await; | ||
| return Err(err.into()); | ||
| } | ||
|
|
||
| Ok(state_transition.into()) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: prepare_document_delete re-implements the nonce-alloc/sign/validate/rollback recipe that already lives in rs-sdk for create/replace
prepare_document_create and prepare_document_replace delegate to build_signed_document_create_transition / build_signed_document_replace_transition, which encapsulate (a) explicit nonce allocation, (b) sign_with_nonce, (c) ensure_valid_state_transition_structure, and (d) rollback_identity_contract_nonce on any pre-broadcast error. prepare_document_delete reimplements that recipe inline here, and packages/rs-sdk-ffi/src/document/delete.rs:158 repeats it a third time. Three independent copies of a consensus-adjacent rollback contract is a drift hazard — if the rollback policy changes (e.g. adding tracing, a conditional refresh, or a structural retry) at least one site will be missed. Promote a build_signed_document_delete_transition helper next to the create/replace helpers in packages/rs-sdk/src/platform/transition/put_document.rs (or in the delete builder module) and funnel all three call sites through it.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 910-952: `prepare_document_delete` re-implements the nonce-alloc/sign/validate/rollback recipe that already lives in rs-sdk for create/replace
`prepare_document_create` and `prepare_document_replace` delegate to `build_signed_document_create_transition` / `build_signed_document_replace_transition`, which encapsulate (a) explicit nonce allocation, (b) `sign_with_nonce`, (c) `ensure_valid_state_transition_structure`, and (d) `rollback_identity_contract_nonce` on any pre-broadcast error. `prepare_document_delete` reimplements that recipe inline here, and `packages/rs-sdk-ffi/src/document/delete.rs:158` repeats it a third time. Three independent copies of a consensus-adjacent rollback contract is a drift hazard — if the rollback policy changes (e.g. adding tracing, a conditional refresh, or a structural retry) at least one site will be missed. Promote a `build_signed_document_delete_transition` helper next to the create/replace helpers in `packages/rs-sdk/src/platform/transition/put_document.rs` (or in the delete builder module) and funnel all three call sites through it.
| @@ -58,64 +441,56 @@ impl<S: Signer<IdentityPublicKey>> PutDocument<S> for Document { | |||
| signer: &S, | |||
| settings: Option<PutSettings>, | |||
| ) -> Result<StateTransition, Error> { | |||
| let new_identity_contract_nonce = sdk | |||
| .get_identity_contract_nonce( | |||
| self.owner_id(), | |||
| document_type.data_contract_id(), | |||
| true, | |||
| settings, | |||
| ) | |||
| .await?; | |||
|
|
|||
| let settings = settings.unwrap_or_default(); | |||
| let transition = if self.revision().is_some() | |||
| && self.revision().unwrap() != INITIAL_REVISION | |||
| { | |||
| BatchTransition::new_document_replacement_transition_from_document( | |||
| self.clone(), | |||
| document_type.as_ref(), | |||
| // Route through the strict create/replace helpers so callers get the | |||
| // same fail-fast revision-vs-intent guarantees as the wasm-sdk | |||
| // `prepareDocumentCreate` / `prepareDocumentReplace` paths. The | |||
| // dispatch is driven by the document revision: unset or | |||
| // `INITIAL_REVISION` selects create; revisions strictly greater than | |||
| // `INITIAL_REVISION` select replace; `Some(0)` is rejected by the | |||
| // strict replace helper before any nonce allocation. | |||
| let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) { | |||
| // Create path. Preserve legacy behavior: when the caller did not | |||
| // supply entropy, generate it and rewrite `document.id` so the | |||
| // pair stays consistent before we hand the (document, entropy) | |||
| // to the strict create helper. The strict helper still verifies | |||
| // that `document.id == generate_document_id_v0(entropy)` before | |||
| // allocating any nonce, so the legacy fallback cannot mask an | |||
| // id/entropy mismatch. | |||
| let (resolved_document, resolved_entropy) = resolve_document_create_entropy( | |||
| self, | |||
| &document_type, | |||
| document_state_transition_entropy, | |||
| ); | |||
| build_signed_document_create_transition( | |||
| sdk, | |||
| &resolved_document, | |||
| &document_type, | |||
| resolved_entropy, | |||
| &identity_public_key, | |||
| new_identity_contract_nonce, | |||
| settings.user_fee_increase.unwrap_or_default(), | |||
| token_payment_info, | |||
| signer, | |||
| sdk.version(), | |||
| settings.state_transition_creation_options, | |||
| settings, | |||
| ) | |||
| .await? | |||
| } else { | |||
| let (document, document_state_transition_entropy) = document_state_transition_entropy | |||
| .map(|entropy| (self.clone(), entropy)) | |||
| .unwrap_or_else(|| { | |||
| let mut rng = StdRng::from_entropy(); | |||
| let mut document = self.clone(); | |||
| let entropy = rng.gen::<[u8; 32]>(); | |||
| document.set_id(Document::generate_document_id_v0( | |||
| &document_type.data_contract_id(), | |||
| &document.owner_id(), | |||
| document_type.name(), | |||
| entropy.as_slice(), | |||
| )); | |||
| (document, entropy) | |||
| }); | |||
| BatchTransition::new_document_creation_transition_from_document( | |||
| document, | |||
| document_type.as_ref(), | |||
| document_state_transition_entropy, | |||
| // Replace path: entropy is unused; the strict helper enforces | |||
| // `revision > INITIAL_REVISION`. | |||
| build_signed_document_replace_transition( | |||
| sdk, | |||
| self, | |||
| &document_type, | |||
| &identity_public_key, | |||
| new_identity_contract_nonce, | |||
| settings.user_fee_increase.unwrap_or_default(), | |||
| token_payment_info, | |||
| signer, | |||
| sdk.version(), | |||
| settings.state_transition_creation_options, | |||
| settings, | |||
| ) | |||
| .await? | |||
| }; | |||
| ensure_valid_state_transition_structure(&transition, sdk.version())?; | |||
|
|
|||
| // response is empty for a broadcast, result comes from the stream wait for state transition result | |||
| transition.broadcast(sdk, Some(settings)).await?; | |||
| transition | |||
| .broadcast(sdk, Some(settings.unwrap_or_default())) | |||
| .await?; | |||
| Ok(transition) | |||
| } | |||
There was a problem hiding this comment.
🟡 Suggestion: Legacy PutDocument::put_to_platform now rejects Some(entropy) whose derived id ≠ document.id locally — undocumented compat change
Before this PR, PutDocument::put_to_platform with document_state_transition_entropy = Some(e) passed the entropy and the caller's document.id through to BatchTransition::new_document_creation_transition_from_document unchecked; mismatches were rejected server-side. After the refactor, put_to_platform routes the create path through build_signed_document_create_transition (line 464), whose ensure_document_id_matches_entropy guard rejects mismatches locally with Error::Generic("InvalidArgument: document.id does not match generate_document_id_v0(...)"). In-tree callers (rs-platform-wallet profile creation, etc.) all pass None, which still flows through resolve_document_create_entropy and is unaffected. But external rs-sdk consumers that built document.id themselves and supplied entropy explicitly will now hit a hard local error where the old code failed remotely. The new behavior is strictly more correct; it just isn't called out alongside the documented revision tightening. Add a line to the compat note in the module docstring (and the PR description) covering the entropy/id tightening for Some(entropy) callers.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 434-495: Legacy `PutDocument::put_to_platform` now rejects `Some(entropy)` whose derived id ≠ `document.id` locally — undocumented compat change
Before this PR, `PutDocument::put_to_platform` with `document_state_transition_entropy = Some(e)` passed the entropy and the caller's `document.id` through to `BatchTransition::new_document_creation_transition_from_document` unchecked; mismatches were rejected server-side. After the refactor, `put_to_platform` routes the create path through `build_signed_document_create_transition` (line 464), whose `ensure_document_id_matches_entropy` guard rejects mismatches locally with `Error::Generic("InvalidArgument: document.id does not match generate_document_id_v0(...)")`. In-tree callers (`rs-platform-wallet` profile creation, etc.) all pass `None`, which still flows through `resolve_document_create_entropy` and is unaffected. But external rs-sdk consumers that built `document.id` themselves and supplied entropy explicitly will now hit a hard local error where the old code failed remotely. The new behavior is strictly more correct; it just isn't called out alongside the documented revision tightening. Add a line to the compat note in the module docstring (and the PR description) covering the entropy/id tightening for `Some(entropy)` callers.
| fn ensure_document_create_revision( | ||
| revision: Option<u64>, | ||
| replace_api_name: &str, | ||
| ) -> Result<(), WasmSdkError> { | ||
| if let Some(revision) = revision { | ||
| if revision != INITIAL_REVISION { | ||
| return Err(WasmSdkError::invalid_argument(format!( | ||
| "Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.", | ||
| revision, INITIAL_REVISION, replace_api_name, | ||
| ))); | ||
| } | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| fn ensure_document_replace_revision( | ||
| revision: Option<u64>, | ||
| create_api_name: &str, | ||
| ) -> Result<(), WasmSdkError> { | ||
| let revision = revision.ok_or_else(|| { | ||
| WasmSdkError::invalid_argument(format!( | ||
| "Document must have a revision set for replace. Use {} for new documents.", | ||
| create_api_name, | ||
| )) | ||
| })?; | ||
|
|
||
| if revision <= INITIAL_REVISION { | ||
| return Err(WasmSdkError::invalid_argument(format!( | ||
| "Document revision is {} but replace requires revision > {}. Use {} for new documents.", | ||
| revision, INITIAL_REVISION, create_api_name, | ||
| ))); | ||
| } | ||
|
|
||
| Ok(()) |
There was a problem hiding this comment.
💬 Nitpick: Revision=0 callers bounce between documentCreate ↔ documentReplace error messages without ever learning the real fix
ensure_document_create_revision with Some(0) errors with "...Use {replace_api_name} for existing documents." ensure_document_replace_revision then rejects Some(0) (<= INITIAL_REVISION) with the symmetric "...Use {create_api_name} for new documents." A confused caller passing revision: 0 bounces between the two APIs without ever being told that revision 0 is always invalid (use unset or 1 for create, > 1 for replace). The rs-sdk side already has ensure_revision_nonzero (put_document.rs:50) emitting a clear "revision 0 is invalid" message before the per-intent guards. Either special-case Some(0) in both wasm-sdk helpers with the same message, or have them delegate to a shared fail-fast helper so users get the actual fix on the first error.
source: ['claude']
| if allocated_nonce == 0 { | ||
| // Nothing to roll back; bumping never produces 0 (it starts at 1). | ||
| return; | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: allocated_nonce == 0 early-return in rollback_identity_contract_nonce is unreachable for any allocator-produced value
The doc-comment on rollback_identity_contract_nonce (and the Sdk wrapper) instructs callers to invoke it only after a successful get_identity_contract_nonce(.., bump_first = true). bump_nonce(0) returns 1 (line 118), so any value produced by that allocation pattern is >= 1. The if allocated_nonce == 0 { return; } guard therefore only ever fires on caller misuse (e.g. passing the raw platform nonce without bumping). Either drop the guard and rely on the current_nonce == allocated_nonce check below (which already wouldn't roll back to -1), or rewrite the inline comment to frame the guard as a misuse-defense rather than a value the allocator can actually produce — the tracing::trace! branch as written is unreachable for the 0 case.
source: ['claude']
| } else { | ||
| let (doc, entropy) = resolve_document_create_entropy( | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| ); | ||
| BatchTransition::new_document_creation_transition_from_document( | ||
| doc, | ||
| document_type.as_ref(), | ||
| entropy, | ||
| identity_public_key, | ||
| new_identity_contract_nonce, | ||
| put_settings.user_fee_increase.unwrap_or_default(), | ||
| token_payment_info, | ||
| signer, | ||
| sdk.version(), | ||
| put_settings.state_transition_creation_options, | ||
| ) | ||
| .await? | ||
| }; |
There was a problem hiding this comment.
💬 Nitpick: None-entropy create branch in the inner dispatcher is unreachable from any current public caller
build_and_sign_create_or_replace_after_nonce still carries the resolve_document_create_entropy(...) fallback that regenerates entropy and rewrites the document id on the create branch when document_state_transition_entropy is None. After this PR every caller of the private dispatcher passes through one of the strict helpers: build_signed_document_create_transition always supplies Some(entropy) (after ensure_document_id_matches_entropy), build_signed_document_replace_transition always takes the replace branch (entropy unused), and the legacy PutDocument::put_to_platform pre-resolves entropy via resolve_document_create_entropy before delegating. The None-entropy create branch is therefore dead from any current call site; it is defensible as a guard against future internal callers but costs a clone+RNG path and obscures the real contract. Either drop the fallback and tighten the parameter to [u8; 32] at this layer, or add a comment that the None branch is reserved for the legacy trait.
source: ['claude']
| // Reject the always-invalid `Some(0)` revision before allocating any | ||
| // nonce. Strict create/replace intent validation is the job of the | ||
| // dedicated helpers below. | ||
| ensure_revision_nonzero(document.revision())?; |
There was a problem hiding this comment.
💬 Nitpick: ensure_revision_nonzero in the inner dispatcher is redundant once both strict wrappers run first
Every public path into build_signed_document_create_or_replace_transition now first runs ensure_revision_for_create or ensure_revision_for_replace, both of which explicitly reject Some(0) (lines 67–75 and 83–94). The ensure_revision_nonzero(document.revision())? call at the top of the dispatcher therefore cannot be reached with Some(0). As written it is defense-in-depth, but it adds an invariant a future maintainer has to keep in sync with the two strict guards. Either remove it or convert it to a debug_assert!, so the strict wrappers remain the single source of truth for create/replace revision policy.
source: ['claude']
| #[derive(Debug)] | ||
| struct AlwaysFailingSigner; | ||
|
|
||
| #[async_trait::async_trait] | ||
| impl dpp::identity::signer::Signer<IdentityPublicKey> for AlwaysFailingSigner { | ||
| async fn sign( | ||
| &self, | ||
| _key: &IdentityPublicKey, | ||
| _data: &[u8], | ||
| ) -> Result<dpp::platform_value::BinaryData, dpp::ProtocolError> { | ||
| Err(dpp::ProtocolError::Generic( | ||
| "deliberate signing failure for delete rollback test".to_string(), | ||
| )) | ||
| } | ||
|
|
||
| async fn sign_create_witness( | ||
| &self, | ||
| _key: &IdentityPublicKey, | ||
| _data: &[u8], | ||
| ) -> Result<dpp::address_funds::AddressWitness, dpp::ProtocolError> { | ||
| unreachable!("not used by document delete transition signing") | ||
| } | ||
|
|
||
| fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool { | ||
| true | ||
| } | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: AlwaysFailingSigner test fixture is duplicated across delete.rs and put_document.rs
The same AlwaysFailingSigner test fixture (identical unreachable! markers and explanatory comments) is reproduced verbatim in both packages/rs-sdk/src/platform/documents/transitions/delete.rs and packages/rs-sdk/src/platform/transition/put_document.rs (~line 600). Both copies will need to be updated in lockstep if the Signer trait gains a method or any of the unreachable! paths becomes reachable. Promote the fixture to a #[cfg(test)] pub(crate) module so both rollback tests share one implementation; this also makes the intent ("deterministic post-allocation signing failure") discoverable in one place.
source: ['claude']
| /// For broadcast failures keep using | ||
| /// [`refresh_identity_nonce`](Self::refresh_identity_nonce), which | ||
| /// preserves the cached (bumped) value so the cache cannot regress past | ||
| /// a nonce the network may have accepted. | ||
| pub async fn rollback_identity_contract_nonce( | ||
| &self, | ||
| identity_id: Identifier, | ||
| contract_id: Identifier, | ||
| allocated_nonce: IdentityNonce, | ||
| ) { | ||
| self.nonce_cache | ||
| .rollback_identity_contract_nonce(identity_id, contract_id, allocated_nonce) | ||
| .await; |
There was a problem hiding this comment.
🟡 Suggestion: Rollback API is identity-contract-only — note this or add the sibling for plain identity nonces before more transitions adopt prepare flows
Sdk::rollback_identity_contract_nonce is the new pre-broadcast rollback primitive, but the analogous identity-nonce path (refresh_identity_nonce / nonce_cache.get_identity_nonce) has no rollback. Document Batch transitions only consume identity-contract nonces today, so the asymmetry is currently fine. Once prepare-style APIs land for transitions that consume the plain identity nonce (identity update, credit transfer, contract create/update — all of which already share the same get_or_fetch_nonce machinery in internal_cache/mod.rs:319), they will silently leak local identity nonces on pre-broadcast failures unless the equivalent rollback exists. Either add rollback_identity_nonce now next to its sibling, or add a doc-comment noting that the rollback API is intentionally identity-contract-only until those transitions are converted.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/sdk.rs`:
- [SUGGESTION] lines 502-514: Rollback API is identity-contract-only — note this or add the sibling for plain identity nonces before more transitions adopt prepare flows
`Sdk::rollback_identity_contract_nonce` is the new pre-broadcast rollback primitive, but the analogous identity-nonce path (`refresh_identity_nonce` / `nonce_cache.get_identity_nonce`) has no rollback. Document Batch transitions only consume identity-contract nonces today, so the asymmetry is currently fine. Once prepare-style APIs land for transitions that consume the plain identity nonce (identity update, credit transfer, contract create/update — all of which already share the same `get_or_fetch_nonce` machinery in `internal_cache/mod.rs:319`), they will silently leak local identity nonces on pre-broadcast failures unless the equivalent rollback exists. Either add `rollback_identity_nonce` now next to its sibling, or add a doc-comment noting that the rollback API is intentionally identity-contract-only until those transitions are converted.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified two-phase prepare APIs and rs-sdk nonce-rollback helpers against the worktree at 8c607ed. No blocking issues survive verification — codex's revision/DocumentFactory blocker is a false positive (the wasm-sdk create path goes through BatchTransition::new_document_creation_transition_from_document → DocumentCreateTransitionV0::from_document, which never reads document.revision(); DocumentFactory is not on this path). Remaining valid items are architecture/docs suggestions around stringly-typed errors, public API surface, and minor duplication.
Reviewed commit: 8c607ed
🟡 4 suggestion(s) | 💬 2 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 63-143: Stringly-typed `Error::Generic("InvalidArgument: …")` for new validation helpers
`ensure_revision_nonzero`, `ensure_revision_for_create`, `ensure_revision_for_replace`, and `ensure_document_id_matches_entropy` all surface caller-input failures by prepending `"InvalidArgument: "` into `Error::Generic(String)`. These are user-facing error modes (`DocumentWasm` lets JS callers set `id` and `entropy` independently via `constructor` + `set_entropy()`, and revision is caller-supplied), so they will routinely bubble through `From<SdkError> for WasmSdkError` as `WasmSdkErrorKind::Generic` — not `InvalidArgument` like the wasm-sdk-local guards. The `Error` enum in `packages/rs-sdk/src/error.rs` already carries `// TODO: Use domain specific errors instead of generic ones` next to `Generic(String)`, and typed variants like `AlreadyExists`/`InvalidCreditTransfer`/`IdentityNonceNotFound` exist. Add an `Error::InvalidArgument(String)` variant (or a typed `InvalidDocumentRevision { … }`) so wasm-sdk and FFI consumers can pattern-match instead of substring-matching the message prefix.
- [SUGGESTION] lines 241-287: `build_signed_document_create_or_replace_transition` left `pub` but only enforces the weak `ensure_revision_nonzero` guard
The dispatch helper is kept `pub` for source compatibility with downstream native callers, but a workspace grep finds no in-tree caller besides the file itself — `PutDocument::put_to_platform` (the path `rs-platform-wallet` uses) now routes through the strict create/replace helpers, not this dispatcher. Because it only runs `ensure_revision_nonzero` and routes by `is_document_replace_revision`, a future contributor reaching for it directly silently gets the weaker validation (e.g. `revision = Some(5)` on what was meant to be a create silently becomes a replace). Either demote to `pub(crate)`, rename (e.g. `..._dispatch`/`..._legacy`) so the call-site reads self-warning, or apply `#[deprecated(note="use build_signed_document_{create,replace}_transition")]`. If a real downstream caller exists in another repo, the doc comment should name it concretely.
- [SUGGESTION] lines 1-37: PR/release notes should call out the rs-sdk `PutDocument::put_to_platform` tightening, not just wasm-sdk
The module-level doc and the `put_to_platform` doc both note the behavior change, but downstream Rust SDK consumers reading only the PR description/changelog will see only the wasm-sdk `documentCreate`/`documentReplace` note. `PutDocument::put_to_platform` is now stricter in two ways too: (1) `revision = Some(0)` is rejected by `ensure_revision_for_replace` (previously routed to the create branch because `0 > INITIAL_REVISION` is false), and (2) when callers pass `Some(entropy)` on the create path, `ensure_document_id_matches_entropy` now rejects mismatches before nonce allocation. The only in-tree native caller (`rs-platform-wallet/src/wallet/identity/network/profile.rs`) uses `revision: None` on create and `Some(current_revision + 1)` on update so it's unaffected, but other downstream Rust SDK users could be surprised. Amend the PR description / release notes to call out the rs-sdk-side tightening alongside the wasm-sdk one.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 135-150: `with_settings` produces two sources of truth for `user_fee_increase`/`state_transition_creation_options`
`with_settings` copies `settings.user_fee_increase` and `settings.state_transition_creation_options` into the builder's own `Option` fields only when those fields are `None`, and *also* stores the whole `settings` on `self.settings`. The result is that those two fields are now read from the builder field for signing while the rest of `PutSettings` continues to be read via `self.settings`. A later refactor that reads `self.settings.user_fee_increase` to recover the effective override will silently see the original `settings` value, not the explicit `with_user_fee_increase` override that won at sign time. Pick one source of truth: either merge *into* `self.settings` (so `self.settings.user_fee_increase` always reflects the effective value), or never store `settings` whole and project just the fields the builder needs.
| fn ensure_revision_nonzero(revision: Option<u64>) -> Result<(), Error> { | ||
| if matches!(revision, Some(0)) { | ||
| return Err(Error::Generic( | ||
| "InvalidArgument: document revision 0 is invalid; \ | ||
| use unset or 1 (INITIAL_REVISION) for create, or > 1 for replace" | ||
| .to_string(), | ||
| )); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Strict revision guard for the document **create** path. | ||
| /// | ||
| /// Accepts `None` and `Some(INITIAL_REVISION)`. Rejects `Some(0)` and any | ||
| /// revision strictly greater than `INITIAL_REVISION`. This is the rs-sdk-side | ||
| /// fail-fast equivalent of the wasm-sdk `ensureDocumentCreateRevision` guard. | ||
| fn ensure_revision_for_create(revision: Option<u64>) -> Result<(), Error> { | ||
| match revision { | ||
| None => Ok(()), | ||
| Some(rev) if rev == INITIAL_REVISION => Ok(()), | ||
| Some(rev) => Err(Error::Generic(format!( | ||
| "InvalidArgument: document revision is {rev} but create requires revision \ | ||
| to be unset or {INITIAL_REVISION}; use the replace path for revisions > {INITIAL_REVISION}" | ||
| ))), | ||
| } | ||
| } | ||
|
|
||
| /// Strict revision guard for the document **replace** path. | ||
| /// | ||
| /// Accepts only `Some(rev)` with `rev > INITIAL_REVISION`. Rejects `None`, | ||
| /// `Some(0)`, and `Some(INITIAL_REVISION)`. This is the rs-sdk-side fail-fast | ||
| /// equivalent of the wasm-sdk `ensureDocumentReplaceRevision` guard. | ||
| fn ensure_revision_for_replace(revision: Option<u64>) -> Result<(), Error> { | ||
| match revision { | ||
| Some(rev) if rev > INITIAL_REVISION => Ok(()), | ||
| Some(rev) => Err(Error::Generic(format!( | ||
| "InvalidArgument: document revision is {rev} but replace requires revision > \ | ||
| {INITIAL_REVISION}; use the create path for new documents" | ||
| ))), | ||
| None => Err(Error::Generic( | ||
| "InvalidArgument: document must have a revision set for replace; \ | ||
| use the create path for new documents" | ||
| .to_string(), | ||
| )), | ||
| } | ||
| } | ||
|
|
||
| /// Strict create-path id check: documents handed to | ||
| /// [`build_signed_document_create_transition`] must already have their `id` | ||
| /// derived from the supplied entropy via [`Document::generate_document_id_v0`]. | ||
| /// | ||
| /// This guards against silently signing a transition whose committed | ||
| /// document id does not match the entropy bound into the create transition. | ||
| /// Callers that want id auto-generation should use the legacy | ||
| /// [`PutDocument::put_to_platform`] trait method, which still accepts | ||
| /// `entropy = None` and rewrites the document id before signing. | ||
| fn ensure_document_id_matches_entropy( | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| entropy: &[u8; 32], | ||
| ) -> Result<(), Error> { | ||
| let expected = Document::generate_document_id_v0( | ||
| &document_type.data_contract_id(), | ||
| &document.owner_id(), | ||
| document_type.name(), | ||
| entropy.as_slice(), | ||
| ); | ||
| if document.id() != expected { | ||
| return Err(Error::Generic(format!( | ||
| "InvalidArgument: document.id does not match \ | ||
| generate_document_id_v0(contract_id, owner_id, document_type_name, entropy); \ | ||
| expected {expected}, got {got}. \ | ||
| Either set document.id to the derived value before calling \ | ||
| build_signed_document_create_transition, or use the legacy \ | ||
| PutDocument::put_to_platform trait method which auto-generates \ | ||
| entropy and rewrites the document id when entropy is None.", | ||
| got = document.id() | ||
| ))); | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Stringly-typed Error::Generic("InvalidArgument: …") for new validation helpers
ensure_revision_nonzero, ensure_revision_for_create, ensure_revision_for_replace, and ensure_document_id_matches_entropy all surface caller-input failures by prepending "InvalidArgument: " into Error::Generic(String). These are user-facing error modes (DocumentWasm lets JS callers set id and entropy independently via constructor + set_entropy(), and revision is caller-supplied), so they will routinely bubble through From<SdkError> for WasmSdkError as WasmSdkErrorKind::Generic — not InvalidArgument like the wasm-sdk-local guards. The Error enum in packages/rs-sdk/src/error.rs already carries // TODO: Use domain specific errors instead of generic ones next to Generic(String), and typed variants like AlreadyExists/InvalidCreditTransfer/IdentityNonceNotFound exist. Add an Error::InvalidArgument(String) variant (or a typed InvalidDocumentRevision { … }) so wasm-sdk and FFI consumers can pattern-match instead of substring-matching the message prefix.
source: ['claude', 'codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 63-143: Stringly-typed `Error::Generic("InvalidArgument: …")` for new validation helpers
`ensure_revision_nonzero`, `ensure_revision_for_create`, `ensure_revision_for_replace`, and `ensure_document_id_matches_entropy` all surface caller-input failures by prepending `"InvalidArgument: "` into `Error::Generic(String)`. These are user-facing error modes (`DocumentWasm` lets JS callers set `id` and `entropy` independently via `constructor` + `set_entropy()`, and revision is caller-supplied), so they will routinely bubble through `From<SdkError> for WasmSdkError` as `WasmSdkErrorKind::Generic` — not `InvalidArgument` like the wasm-sdk-local guards. The `Error` enum in `packages/rs-sdk/src/error.rs` already carries `// TODO: Use domain specific errors instead of generic ones` next to `Generic(String)`, and typed variants like `AlreadyExists`/`InvalidCreditTransfer`/`IdentityNonceNotFound` exist. Add an `Error::InvalidArgument(String)` variant (or a typed `InvalidDocumentRevision { … }`) so wasm-sdk and FFI consumers can pattern-match instead of substring-matching the message prefix.
| pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| document_state_transition_entropy: Option<[u8; 32]>, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| // Reject the always-invalid `Some(0)` revision before allocating any | ||
| // nonce. Strict create/replace intent validation is the job of the | ||
| // dedicated helpers below. | ||
| ensure_revision_nonzero(document.revision())?; | ||
|
|
||
| let owner_id = document.owner_id(); | ||
| let contract_id = document_type.data_contract_id(); | ||
| let new_identity_contract_nonce = sdk | ||
| .get_identity_contract_nonce(owner_id, contract_id, true, settings) | ||
| .await?; | ||
|
|
||
| let result = build_and_sign_create_or_replace_after_nonce( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| new_identity_contract_nonce, | ||
| ) | ||
| .await; | ||
|
|
||
| match result { | ||
| Ok(transition) => Ok(transition), | ||
| Err(err) => { | ||
| sdk.rollback_identity_contract_nonce( | ||
| owner_id, | ||
| contract_id, | ||
| new_identity_contract_nonce, | ||
| ) | ||
| .await; | ||
| Err(err) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: build_signed_document_create_or_replace_transition left pub but only enforces the weak ensure_revision_nonzero guard
The dispatch helper is kept pub for source compatibility with downstream native callers, but a workspace grep finds no in-tree caller besides the file itself — PutDocument::put_to_platform (the path rs-platform-wallet uses) now routes through the strict create/replace helpers, not this dispatcher. Because it only runs ensure_revision_nonzero and routes by is_document_replace_revision, a future contributor reaching for it directly silently gets the weaker validation (e.g. revision = Some(5) on what was meant to be a create silently becomes a replace). Either demote to pub(crate), rename (e.g. ..._dispatch/..._legacy) so the call-site reads self-warning, or apply #[deprecated(note="use build_signed_document_{create,replace}_transition")]. If a real downstream caller exists in another repo, the doc comment should name it concretely.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 241-287: `build_signed_document_create_or_replace_transition` left `pub` but only enforces the weak `ensure_revision_nonzero` guard
The dispatch helper is kept `pub` for source compatibility with downstream native callers, but a workspace grep finds no in-tree caller besides the file itself — `PutDocument::put_to_platform` (the path `rs-platform-wallet` uses) now routes through the strict create/replace helpers, not this dispatcher. Because it only runs `ensure_revision_nonzero` and routes by `is_document_replace_revision`, a future contributor reaching for it directly silently gets the weaker validation (e.g. `revision = Some(5)` on what was meant to be a create silently becomes a replace). Either demote to `pub(crate)`, rename (e.g. `..._dispatch`/`..._legacy`) so the call-site reads self-warning, or apply `#[deprecated(note="use build_signed_document_{create,replace}_transition")]`. If a real downstream caller exists in another repo, the doc comment should name it concretely.
| pub fn with_settings(mut self, settings: PutSettings) -> Self { | ||
| if self.user_fee_increase.is_none() { | ||
| if let Some(user_fee_increase) = settings.user_fee_increase { | ||
| self.user_fee_increase = Some(user_fee_increase); | ||
| } | ||
| } | ||
| if self.state_transition_creation_options.is_none() { | ||
| if let Some(state_transition_creation_options) = | ||
| settings.state_transition_creation_options | ||
| { | ||
| self.state_transition_creation_options = Some(state_transition_creation_options); | ||
| } | ||
| } | ||
| self.settings = Some(settings); | ||
| self | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: with_settings produces two sources of truth for user_fee_increase/state_transition_creation_options
with_settings copies settings.user_fee_increase and settings.state_transition_creation_options into the builder's own Option fields only when those fields are None, and also stores the whole settings on self.settings. The result is that those two fields are now read from the builder field for signing while the rest of PutSettings continues to be read via self.settings. A later refactor that reads self.settings.user_fee_increase to recover the effective override will silently see the original settings value, not the explicit with_user_fee_increase override that won at sign time. Pick one source of truth: either merge into self.settings (so self.settings.user_fee_increase always reflects the effective value), or never store settings whole and project just the fields the builder needs.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 135-150: `with_settings` produces two sources of truth for `user_fee_increase`/`state_transition_creation_options`
`with_settings` copies `settings.user_fee_increase` and `settings.state_transition_creation_options` into the builder's own `Option` fields only when those fields are `None`, and *also* stores the whole `settings` on `self.settings`. The result is that those two fields are now read from the builder field for signing while the rest of `PutSettings` continues to be read via `self.settings`. A later refactor that reads `self.settings.user_fee_increase` to recover the effective override will silently see the original `settings` value, not the explicit `with_user_fee_increase` override that won at sign time. Pick one source of truth: either merge *into* `self.settings` (so `self.settings.user_fee_increase` always reflects the effective value), or never store `settings` whole and project just the fields the builder needs.
| //! Document put / create / replace state-transition builders. | ||
| //! | ||
| //! # Compatibility note (2026-05) | ||
| //! | ||
| //! Two intentionally different create-path entry points coexist: | ||
| //! | ||
| //! - The [`PutDocument::put_to_platform`] trait method is the **legacy | ||
| //! native** entry point. It accepts | ||
| //! `document_state_transition_entropy = None` on the create path and will | ||
| //! auto-generate 32-byte entropy + rewrite `document.id` via | ||
| //! [`Document::generate_document_id_v0`] before signing. In-tree callers | ||
| //! such as `rs-platform-wallet` (DashPay profile creation) rely on this | ||
| //! fallback. | ||
| //! - The strict [`build_signed_document_create_transition`] / | ||
| //! [`build_signed_document_replace_transition`] helpers, used by the | ||
| //! wasm-sdk `prepareDocumentCreate` / `prepareDocumentReplace` flows, do | ||
| //! **not** auto-generate entropy. Callers must supply entropy whose | ||
| //! derived `Document::generate_document_id_v0(...)` matches `document.id`; | ||
| //! a mismatch is rejected before any identity-contract nonce is | ||
| //! allocated. | ||
| //! | ||
| //! New prepare/sign-without-broadcast call sites should prefer the strict | ||
| //! builders so the supplied document id and entropy commit to the same | ||
| //! value. | ||
| //! | ||
| //! Additionally, [`PutDocument::put_to_platform`]'s create path now | ||
| //! locally rejects when `document_state_transition_entropy = Some(entropy)` | ||
| //! and the derived id does not match `document.id` — the strict | ||
| //! [`build_signed_document_create_transition`] helper that backs it | ||
| //! enforces this id-matches-entropy check before any nonce allocation. | ||
| //! Passing `None` still preserves the legacy auto-generate-entropy / | ||
| //! rewrite-id behavior for callers that opt into it. | ||
| //! | ||
| //! [`build_signed_document_create_or_replace_transition`] remains public | ||
| //! for source compatibility with downstream native callers that depended | ||
| //! on it before the strict helpers were introduced. New callers should | ||
| //! prefer the strict create/replace helpers above. |
There was a problem hiding this comment.
🟡 Suggestion: PR/release notes should call out the rs-sdk PutDocument::put_to_platform tightening, not just wasm-sdk
The module-level doc and the put_to_platform doc both note the behavior change, but downstream Rust SDK consumers reading only the PR description/changelog will see only the wasm-sdk documentCreate/documentReplace note. PutDocument::put_to_platform is now stricter in two ways too: (1) revision = Some(0) is rejected by ensure_revision_for_replace (previously routed to the create branch because 0 > INITIAL_REVISION is false), and (2) when callers pass Some(entropy) on the create path, ensure_document_id_matches_entropy now rejects mismatches before nonce allocation. The only in-tree native caller (rs-platform-wallet/src/wallet/identity/network/profile.rs) uses revision: None on create and Some(current_revision + 1) on update so it's unaffected, but other downstream Rust SDK users could be surprised. Amend the PR description / release notes to call out the rs-sdk-side tightening alongside the wasm-sdk one.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 1-37: PR/release notes should call out the rs-sdk `PutDocument::put_to_platform` tightening, not just wasm-sdk
The module-level doc and the `put_to_platform` doc both note the behavior change, but downstream Rust SDK consumers reading only the PR description/changelog will see only the wasm-sdk `documentCreate`/`documentReplace` note. `PutDocument::put_to_platform` is now stricter in two ways too: (1) `revision = Some(0)` is rejected by `ensure_revision_for_replace` (previously routed to the create branch because `0 > INITIAL_REVISION` is false), and (2) when callers pass `Some(entropy)` on the create path, `ensure_document_id_matches_entropy` now rejects mismatches before nonce allocation. The only in-tree native caller (`rs-platform-wallet/src/wallet/identity/network/profile.rs`) uses `revision: None` on create and `Some(current_revision + 1)` on update so it's unaffected, but other downstream Rust SDK users could be surprised. Amend the PR description / release notes to call out the rs-sdk-side tightening alongside the wasm-sdk one.
| pub mod unshield; | ||
| pub mod update_price_of_document; | ||
| pub(crate) mod validation; | ||
| pub mod validation; |
There was a problem hiding this comment.
💬 Nitpick: pub mod validation exports the whole module just to surface one helper
Changing pub(crate) mod validation to pub mod validation exposes every item in the module (including the private map_validation_result / is_unsupported_feature_error helpers and any future additions) just so wasm-sdk tests can import ensure_valid_state_transition_structure. Keep the module pub(crate) and add a targeted pub use platform::transition::validation::ensure_valid_state_transition_structure; re-export at the crate or pub mod transition level — that preserves freedom to reorganise the module's internals without a semver concern.
source: ['claude']
| } | ||
| } | ||
|
|
||
| // ============================================================================ | ||
| // Prepare Document Create (Two-Phase API) | ||
| // ============================================================================ | ||
|
|
||
| /// TypeScript interface for prepare document create options | ||
| #[wasm_bindgen(typescript_custom_section)] | ||
| const PREPARE_DOCUMENT_CREATE_OPTIONS_TS: &'static str = r#" | ||
| /** | ||
| * Options for preparing a document creation state transition without broadcasting. | ||
| * | ||
| * Use this for idempotent retry patterns: | ||
| * 1. Call `prepareDocumentCreate()` to get a signed `StateTransition` | ||
| * 2. Cache `stateTransition.toBytes()` for retry safety | ||
| * 3. Call `broadcastStateTransition(st)` + `waitForResponse(st)` | ||
| * 4. On timeout, deserialize cached bytes and rebroadcast the **identical** ST | ||
| */ | ||
| export interface PrepareDocumentCreateOptions { | ||
| /** The document to create. */ | ||
| document: Document; | ||
| /** The identity public key to use for signing. */ | ||
| identityKey: IdentityPublicKey; | ||
| /** Signer containing the private key for the identity key. */ | ||
| signer: IdentitySigner; | ||
| /** Optional token payment agreement for document types with tokenCost.create. */ | ||
| tokenPaymentInfo?: DocumentTokenPaymentInfo; | ||
| /** Optional settings (retries, timeouts, userFeeIncrease). */ | ||
| settings?: PutSettings; | ||
| } | ||
| "#; | ||
|
|
||
| #[wasm_bindgen] | ||
| extern "C" { | ||
| #[wasm_bindgen(typescript_type = "PrepareDocumentCreateOptions")] | ||
| pub type PrepareDocumentCreateOptionsJs; | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| impl WasmSdk { | ||
| /// Prepare a document creation state transition without broadcasting. | ||
| /// | ||
| /// This method handles nonce management, ST construction, and signing, but does | ||
| /// **not** broadcast or wait for a response. The returned `StateTransition` can be: | ||
| /// | ||
| /// - Serialized with `toBytes()` and cached for retry safety | ||
| /// - Broadcast with `broadcastStateTransition(st)` | ||
| /// - Awaited with `waitForResponse(st)` | ||
| /// | ||
| /// This is the "prepare" half of the two-phase API. Use it when you need | ||
| /// idempotent retry behavior — on timeout, you can rebroadcast the exact same | ||
| /// signed transition instead of creating a new one with a new nonce. | ||
| /// | ||
| /// **Nonce consumption:** A successful call advances this SDK instance's local | ||
| /// identity-contract nonce cache and embeds that nonce in the signed transition. | ||
| /// Platform state is not mutated until broadcast/processing. Only call this when | ||
| /// you intend to broadcast / persist-and-retry the returned transition. See module | ||
| /// docs for details. | ||
| /// | ||
| /// @param options - Creation options including document, identity key, and signer | ||
| /// @returns The signed StateTransition ready for broadcasting | ||
| #[wasm_bindgen(js_name = "prepareDocumentCreate")] | ||
| pub async fn prepare_document_create( | ||
| &self, | ||
| options: PrepareDocumentCreateOptionsJs, | ||
| ) -> Result<StateTransitionWasm, WasmSdkError> { | ||
| // Extract document from options | ||
| let document_wasm = DocumentWasm::try_from_options(&options, "document")?; | ||
| let document: Document = document_wasm.clone().into(); | ||
|
|
||
| ensure_document_create_revision(document.revision(), "prepareDocumentReplace")?; | ||
|
|
||
| // Get metadata from document | ||
| let contract_id: Identifier = document_wasm.data_contract_id().into(); | ||
| let document_type_name = document_wasm.document_type_name(); | ||
|
|
||
| // Get entropy from document | ||
| let entropy = document_wasm.entropy().ok_or_else(|| { | ||
| WasmSdkError::invalid_argument("Document must have entropy set for creation") | ||
| })?; | ||
|
|
||
| if entropy.len() != 32 { | ||
| return Err(WasmSdkError::invalid_argument( | ||
| "Document entropy must be exactly 32 bytes", | ||
| )); | ||
| } | ||
|
|
||
| let mut entropy_array = [0u8; 32]; | ||
| entropy_array.copy_from_slice(&entropy); | ||
|
|
||
| // Extract identity key from options | ||
| let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; | ||
| let identity_key: IdentityPublicKey = identity_key_wasm.into(); | ||
|
|
||
| // Extract signer from options | ||
| let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; | ||
|
|
||
| // Fetch the data contract (using cache) | ||
| let data_contract = self.get_or_fetch_contract(contract_id).await?; | ||
|
|
||
| // Get document type (owned) | ||
| let document_type = get_document_type(&data_contract, &document_type_name)?; | ||
|
|
||
| // Extract settings from options | ||
| let settings = | ||
| try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into); | ||
| let token_payment_info = try_from_options_optional_token_payment_info(&options)?; | ||
|
|
||
| // Build, sign, and structurally validate the state transition without | ||
| // broadcasting it. Local pre-broadcast failures are rolled back inside | ||
| // rs-sdk so the identity-contract nonce cache cannot advance past a | ||
| // nonce the network never observed. | ||
| let state_transition = build_signed_document_create_transition( | ||
| self.inner_sdk(), | ||
| &document, | ||
| &document_type, | ||
| entropy_array, | ||
| &identity_key, | ||
| token_payment_info, | ||
| &signer, | ||
| settings, | ||
| ) | ||
| .await?; | ||
|
|
||
| Ok(state_transition.into()) | ||
| } | ||
| } | ||
|
|
||
| // ============================================================================ | ||
| // Prepare Document Replace (Two-Phase API) | ||
| // ============================================================================ | ||
|
|
||
| /// TypeScript interface for prepare document replace options | ||
| #[wasm_bindgen(typescript_custom_section)] | ||
| const PREPARE_DOCUMENT_REPLACE_OPTIONS_TS: &'static str = r#" | ||
| /** | ||
| * Options for preparing a document replace state transition without broadcasting. | ||
| * | ||
| * Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern. | ||
| */ | ||
| export interface PrepareDocumentReplaceOptions { | ||
| /** The document with updated data (same ID, incremented revision). */ | ||
| document: Document; | ||
| /** The identity public key to use for signing. */ | ||
| identityKey: IdentityPublicKey; | ||
| /** Signer containing the private key for the identity key. */ | ||
| signer: IdentitySigner; | ||
| /** Optional token payment agreement for document types with tokenCost.replace. */ | ||
| tokenPaymentInfo?: DocumentTokenPaymentInfo; | ||
| /** Optional settings (retries, timeouts, userFeeIncrease). */ | ||
| settings?: PutSettings; | ||
| } | ||
| "#; | ||
|
|
||
| #[wasm_bindgen] | ||
| extern "C" { | ||
| #[wasm_bindgen(typescript_type = "PrepareDocumentReplaceOptions")] | ||
| pub type PrepareDocumentReplaceOptionsJs; | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| impl WasmSdk { | ||
| /// Prepare a document replace state transition without broadcasting. | ||
| /// | ||
| /// This method handles nonce management, ST construction, and signing, but does | ||
| /// **not** broadcast or wait for a response. See `prepareDocumentCreate` for | ||
| /// the full two-phase usage pattern. | ||
| /// | ||
| /// **Nonce consumption:** A successful call advances this SDK instance's local | ||
| /// identity-contract nonce cache and embeds that nonce in the signed transition. | ||
| /// Platform state is not mutated until broadcast/processing. Only call this when | ||
| /// you intend to broadcast / persist-and-retry the returned transition. See module | ||
| /// docs for details. | ||
| /// | ||
| /// @param options - Replace options including document, identity key, and signer | ||
| /// @returns The signed StateTransition ready for broadcasting | ||
| #[wasm_bindgen(js_name = "prepareDocumentReplace")] | ||
| pub async fn prepare_document_replace( | ||
| &self, | ||
| options: PrepareDocumentReplaceOptionsJs, | ||
| ) -> Result<StateTransitionWasm, WasmSdkError> { | ||
| // Extract document from options | ||
| let document_wasm = DocumentWasm::try_from_options(&options, "document")?; | ||
| let document: Document = document_wasm.clone().into(); | ||
|
|
||
| ensure_document_replace_revision(document.revision(), "prepareDocumentCreate")?; | ||
|
|
||
| // Get metadata from document | ||
| let contract_id: Identifier = document_wasm.data_contract_id().into(); | ||
| let document_type_name = document_wasm.document_type_name(); | ||
|
|
||
| // Extract identity key from options | ||
| let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; | ||
| let identity_key: IdentityPublicKey = identity_key_wasm.into(); | ||
|
|
||
| // Extract signer from options | ||
| let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; | ||
|
|
||
| // Fetch the data contract (using cache) | ||
| let data_contract = self.get_or_fetch_contract(contract_id).await?; | ||
|
|
||
| // Get document type (owned) | ||
| let document_type = get_document_type(&data_contract, &document_type_name)?; | ||
|
|
||
| // Extract settings from options | ||
| let settings = | ||
| try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into); | ||
| let token_payment_info = try_from_options_optional_token_payment_info(&options)?; | ||
|
|
||
| // Build, sign, and structurally validate the state transition without | ||
| // broadcasting it. Local pre-broadcast failures are rolled back inside | ||
| // rs-sdk so the identity-contract nonce cache cannot advance past a | ||
| // nonce the network never observed. | ||
| let state_transition = build_signed_document_replace_transition( | ||
| self.inner_sdk(), | ||
| &document, | ||
| &document_type, | ||
| &identity_key, | ||
| token_payment_info, | ||
| &signer, | ||
| settings, | ||
| ) | ||
| .await?; | ||
|
|
||
| Ok(state_transition.into()) | ||
| } | ||
| } | ||
|
|
||
| // ============================================================================ | ||
| // Prepare Document Delete (Two-Phase API) | ||
| // ============================================================================ | ||
|
|
||
| /// TypeScript interface for prepare document delete options | ||
| #[wasm_bindgen(typescript_custom_section)] | ||
| const PREPARE_DOCUMENT_DELETE_OPTIONS_TS: &'static str = r#" | ||
| /** | ||
| * Options for preparing a document delete state transition without broadcasting. | ||
| * | ||
| * Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern. | ||
| */ | ||
| export interface PrepareDocumentDeleteOptions { | ||
| /** | ||
| * The document to delete — either a Document instance or an object with identifiers. | ||
| */ | ||
| document: Document | { | ||
| id: IdentifierLike; | ||
| ownerId: IdentifierLike; | ||
| dataContractId: IdentifierLike; | ||
| documentTypeName: string; | ||
| }; | ||
| /** The identity public key to use for signing. */ | ||
| identityKey: IdentityPublicKey; | ||
| /** Signer containing the private key for the identity key. */ | ||
| signer: IdentitySigner; | ||
| /** Optional token payment agreement for document types with tokenCost.delete. */ | ||
| tokenPaymentInfo?: DocumentTokenPaymentInfo; | ||
| /** Optional settings (retries, timeouts, userFeeIncrease). */ | ||
| settings?: PutSettings; | ||
| } | ||
| "#; | ||
|
|
||
| #[wasm_bindgen] | ||
| extern "C" { | ||
| #[wasm_bindgen(typescript_type = "PrepareDocumentDeleteOptions")] | ||
| pub type PrepareDocumentDeleteOptionsJs; | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| impl WasmSdk { | ||
| /// Prepare a document delete state transition without broadcasting. | ||
| /// | ||
| /// This method handles nonce management, ST construction, and signing, but does | ||
| /// **not** broadcast or wait for a response. See `prepareDocumentCreate` for | ||
| /// the full two-phase usage pattern. | ||
| /// | ||
| /// **Nonce consumption:** A successful call advances this SDK instance's local | ||
| /// identity-contract nonce cache and embeds that nonce in the signed transition. | ||
| /// Platform state is not mutated until broadcast/processing. Only call this when | ||
| /// you intend to broadcast / persist-and-retry the returned transition. See module | ||
| /// docs for details. | ||
| /// | ||
| /// @param options - Delete options including document identifiers, identity key, and signer | ||
| /// @returns The signed StateTransition ready for broadcasting | ||
| #[wasm_bindgen(js_name = "prepareDocumentDelete")] | ||
| pub async fn prepare_document_delete( | ||
| &self, | ||
| options: PrepareDocumentDeleteOptionsJs, | ||
| ) -> Result<StateTransitionWasm, WasmSdkError> { | ||
| // Extract document field - can be either a Document instance or plain object | ||
| let document_js = js_sys::Reflect::get(&options, &JsValue::from_str("document")) | ||
| .map_err(|_| WasmSdkError::invalid_argument("document is required"))?; | ||
|
|
||
| if document_js.is_undefined() || document_js.is_null() { | ||
| return Err(WasmSdkError::invalid_argument("document is required")); | ||
| } | ||
|
|
||
| // Check if it's a Document instance or a plain object with fields | ||
| let (document_id, owner_id, contract_id, document_type_name): ( | ||
| Identifier, | ||
| Identifier, | ||
| Identifier, | ||
| String, | ||
| ) = if get_class_type(&document_js).ok().as_deref() == Some("Document") { | ||
| let doc: DocumentWasm = document_js | ||
| .to_wasm::<DocumentWasm>("Document") | ||
| .map(|boxed| (*boxed).clone())?; | ||
| let doc_inner: Document = doc.clone().into(); | ||
| ( | ||
| doc.id().into(), | ||
| doc_inner.owner_id(), | ||
| doc.data_contract_id().into(), | ||
| doc.document_type_name(), | ||
| ) | ||
| } else { | ||
| ( | ||
| IdentifierWasm::try_from_options(&document_js, "id")?.into(), | ||
| IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(), | ||
| IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(), | ||
| try_from_options_with(&document_js, "documentTypeName", |v| { | ||
| try_to_string(v, "documentTypeName") | ||
| })?, | ||
| ) | ||
| }; | ||
|
|
||
| // Extract identity key from options | ||
| let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; | ||
| let identity_key: IdentityPublicKey = identity_key_wasm.into(); | ||
|
|
||
| // Extract signer from options | ||
| let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; | ||
|
|
||
| // Fetch the data contract (using cache) | ||
| let data_contract = self.get_or_fetch_contract(contract_id).await?; | ||
|
|
||
| // Extract settings from options | ||
| let settings = | ||
| try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into); | ||
| let token_payment_info = try_from_options_optional_token_payment_info(&options)?; | ||
|
|
||
| // Build the delete transition using the builder's sign method (which does NOT broadcast) | ||
| let builder = DocumentDeleteTransitionBuilder::new( | ||
| Arc::new(data_contract), | ||
| document_type_name, | ||
| document_id, | ||
| owner_id, | ||
| ); | ||
|
|
||
| let builder = if let Some(token_payment_info) = token_payment_info { | ||
| builder.with_token_payment_info(token_payment_info) | ||
| } else { | ||
| builder | ||
| }; | ||
|
|
||
| let builder = if let Some(s) = settings { | ||
| builder.with_settings(s) | ||
| } else { | ||
| builder | ||
| }; |
There was a problem hiding this comment.
💬 Nitpick: Document-vs-plain-object extraction duplicated between document_delete and prepare_document_delete
The class-detection + field-extraction block in document_delete (~lines 481–509) is duplicated almost verbatim in prepare_document_delete (~lines 848–874), and both then construct the same DocumentDeleteTransitionBuilder (lines 526–544 vs 891–909). Extract a helper (e.g. extract_document_delete_inputs(document_js) -> Result<(Identifier, Identifier, Identifier, String), WasmSdkError>) plus a builder-construction helper to remove ~40 lines of duplication and keep the one-shot and prepare paths in lock-step if validation is tightened later.
source: ['claude']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The PR is sound: strict create/replace helpers, shared delete helper, conditional pre-broadcast nonce rollback, and broader test coverage are all well-modeled. The most substantive remaining concern is consistency — the sibling DocumentCreateTransitionBuilder / DocumentReplaceTransitionBuilder paths in packages/rs-sdk/src/platform/documents/transitions/{create,replace}.rs still bump the identity-contract nonce without a rollback on local failure. Those files are unchanged in this PR (pre-existing condition), so this is a follow-up rather than a regression. All other findings are docs / test-coverage / minor architecture suggestions.
Reviewed commit: 3ba4b98
🟡 6 suggestion(s) | 💬 4 nitpick(s)
3 additional findings
🟡 suggestion: DocumentCreateTransitionBuilder::sign leaks the bumped identity-contract nonce on local failure
packages/rs-sdk/src/platform/documents/transitions/create.rs (lines 135-171)
DocumentCreateTransitionBuilder::sign() (called from Sdk::document_create) allocates the identity-contract nonce with bump_first = true, then awaits BatchTransition::new_document_creation_transition_from_document(...). Any error from that constructor (signer error, document_type lookup, build/validation) is returned directly with no Sdk::rollback_identity_contract_nonce call. The local cache is left at N+1 while the network still expects N, so the next create from the same Sdk instance must hit refresh_identity_nonce before it can succeed. The new put_document.rs legacy dispatcher and the shared delete helper already roll back identically positioned failures via Sdk::rollback_identity_contract_nonce, so leaving this builder out is an inconsistency, not a deliberate design choice. This file is not modified by this PR, so this is a pre-existing gap rather than a new regression, but it falls in scope of the PR's stated goal of tightening document-transition nonce handling and should be addressed in this PR or an immediate follow-up.
🟡 suggestion: DocumentReplaceTransitionBuilder::sign leaks the bumped identity-contract nonce on local failure
packages/rs-sdk/src/platform/documents/transitions/replace.rs (lines 130-165)
Same shape as the create builder: DocumentReplaceTransitionBuilder::sign() (called from Sdk::document_replace) bumps the identity-contract nonce, then awaits new_document_replacement_transition_from_document(...), and returns any error without rolling the nonce back. Locally-recoverable failures (signer errors, build/validation errors) leave the cache ahead of Platform; the next replace through this builder will sign with an off-by-one nonce until refresh_identity_nonce runs. The put_document legacy dispatcher already handles this for the trait-based path, so the builder API is the only document-transition entry point still missing the rollback. Like the create builder, this file is not modified by this PR — pre-existing gap, but in scope for the PR's nonce-handling theme.
💬 nitpick: Bincode encode failure after prepare leaves the bumped nonce un-rolled-back
packages/rs-sdk-ffi/src/document/delete.rs (lines 175-196)
The FFI delete prepare path rolls back the identity-contract nonce on every failure inside build_signed_document_delete_transition, but the subsequent bincode::encode_to_vec(&state_transition, config) returns a Result and a failure would leave the bumped nonce in the cache until the caller refreshes it. The inline comment explains this is effectively infallible for any transition the helper just returned, which is accurate — encoding an already-built valid StateTransition has no IO and no realistic failure mode. This is a defense-in-depth nit rather than a real bug; capture (owner_id, contract_id, allocated nonce) alongside the transition and roll back on encode error if you want symmetry with the helper's own rollback discipline.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 135-171: DocumentCreateTransitionBuilder::sign leaks the bumped identity-contract nonce on local failure
`DocumentCreateTransitionBuilder::sign()` (called from `Sdk::document_create`) allocates the identity-contract nonce with `bump_first = true`, then awaits `BatchTransition::new_document_creation_transition_from_document(...)`. Any error from that constructor (signer error, document_type lookup, build/validation) is returned directly with no `Sdk::rollback_identity_contract_nonce` call. The local cache is left at `N+1` while the network still expects `N`, so the next create from the same Sdk instance must hit `refresh_identity_nonce` before it can succeed. The new `put_document.rs` legacy dispatcher and the shared delete helper already roll back identically positioned failures via `Sdk::rollback_identity_contract_nonce`, so leaving this builder out is an inconsistency, not a deliberate design choice. This file is not modified by this PR, so this is a pre-existing gap rather than a new regression, but it falls in scope of the PR's stated goal of tightening document-transition nonce handling and should be addressed in this PR or an immediate follow-up.
In `packages/rs-sdk/src/platform/documents/transitions/replace.rs`:
- [SUGGESTION] lines 130-165: DocumentReplaceTransitionBuilder::sign leaks the bumped identity-contract nonce on local failure
Same shape as the create builder: `DocumentReplaceTransitionBuilder::sign()` (called from `Sdk::document_replace`) bumps the identity-contract nonce, then awaits `new_document_replacement_transition_from_document(...)`, and returns any error without rolling the nonce back. Locally-recoverable failures (signer errors, build/validation errors) leave the cache ahead of Platform; the next replace through this builder will sign with an off-by-one nonce until `refresh_identity_nonce` runs. The put_document legacy dispatcher already handles this for the trait-based path, so the builder API is the only document-transition entry point still missing the rollback. Like the create builder, this file is not modified by this PR — pre-existing gap, but in scope for the PR's nonce-handling theme.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 95-108: ensure_revision_for_replace error message is misleading for PutDocument::put_to_platform callers
`put_to_platform` dispatches `revision == Some(0)` to the replace branch (since `revision().is_none() || revision() == Some(INITIAL_REVISION)` is false), which calls `build_signed_document_replace_transition` → `ensure_revision_for_replace`. That helper returns 'document revision is 0 but replace requires revision > 1; use the create path for new documents'. Callers of the unified `PutDocument::put_to_platform` trait don't see the internal replace routing — telling them to 'use the create path' is opaque and contradicts the routing rules in this same module. Either route `Some(0)` explicitly to `ensure_revision_nonzero` before dispatch, or reword the message to something true regardless of caller: 'revision 0 is invalid; use unset or 1 for create, or > 1 for replace'.
- [SUGGESTION] lines 392-413: Replace-side pre-broadcast rollback is not directly exercised by tests
`build_signed_document_replace_transition` is a new public strict entry point with its own revision guard, but the test module only proves rollback behavior for the create helper. The replace branch of the revision-driven dispatcher and its rollback path are unexercised — a future change to `is_document_replace_revision`, the replace wrapper, or the shared legacy dispatch could break rollback or accidentally route replaces through the create branch while the existing create-focused tests still pass. Add a mock-based async test for a replace transition that fails after nonce allocation and asserts the next allocation reuses the rolled-back value.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 120-204: DocumentDeleteTransitionBuilder keeps user_fee_increase / state_transition_creation_options in two places
Both `self.user_fee_increase` / `self.state_transition_creation_options` and `self.settings.{user_fee_increase, state_transition_creation_options}` must be kept in sync, and all three setters (`with_user_fee_increase`, `with_state_transition_creation_options`, `with_settings`) now carry reconciliation logic to preserve 'explicit setter wins'. Tests cover every call order today, but a future contributor extending `with_settings` or adding a third overlapping field is likely to miss the sync requirement. Consider storing these only on `self.settings` (or only on the dedicated fields) and computing the effective value at sign time so there is a single source of truth.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 613-922: New prepareDocument* WASM entry points have no wrapper-level tests
The three `prepareDocument*` methods contain non-trivial wrapper logic — JS option extraction (`Reflect::get` + `unchecked_into`), entropy length validation, the `Document`-vs-plain-object split for delete, and conversion back into `StateTransitionWasm` — that is invisible to Rust's type system at the `JsValue` boundary. The current tests in this file cover only pure revision guards and an unrelated structure-validation helper, and `tests/unit/prepare-document.spec.ts` only validates argument-shape errors. Add at least one wrapper-level test per prepare method (mismatched entropy/id, bad entropy length, invalid options object) so regressions in field naming, branch selection, or error mapping fail loudly.
| fn ensure_revision_for_replace(revision: Option<u64>) -> Result<(), Error> { | ||
| match revision { | ||
| Some(rev) if rev > INITIAL_REVISION => Ok(()), | ||
| Some(rev) => Err(Error::InvalidArgument(format!( | ||
| "document revision is {rev} but replace requires revision > \ | ||
| {INITIAL_REVISION}; use the create path for new documents" | ||
| ))), | ||
| None => Err(Error::InvalidArgument( | ||
| "document must have a revision set for replace; \ | ||
| use the create path for new documents" | ||
| .to_string(), | ||
| )), | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: ensure_revision_for_replace error message is misleading for PutDocument::put_to_platform callers
put_to_platform dispatches revision == Some(0) to the replace branch (since revision().is_none() || revision() == Some(INITIAL_REVISION) is false), which calls build_signed_document_replace_transition → ensure_revision_for_replace. That helper returns 'document revision is 0 but replace requires revision > 1; use the create path for new documents'. Callers of the unified PutDocument::put_to_platform trait don't see the internal replace routing — telling them to 'use the create path' is opaque and contradicts the routing rules in this same module. Either route Some(0) explicitly to ensure_revision_nonzero before dispatch, or reword the message to something true regardless of caller: 'revision 0 is invalid; use unset or 1 for create, or > 1 for replace'.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 95-108: ensure_revision_for_replace error message is misleading for PutDocument::put_to_platform callers
`put_to_platform` dispatches `revision == Some(0)` to the replace branch (since `revision().is_none() || revision() == Some(INITIAL_REVISION)` is false), which calls `build_signed_document_replace_transition` → `ensure_revision_for_replace`. That helper returns 'document revision is 0 but replace requires revision > 1; use the create path for new documents'. Callers of the unified `PutDocument::put_to_platform` trait don't see the internal replace routing — telling them to 'use the create path' is opaque and contradicts the routing rules in this same module. Either route `Some(0)` explicitly to `ensure_revision_nonzero` before dispatch, or reword the message to something true regardless of caller: 'revision 0 is invalid; use unset or 1 for create, or > 1 for replace'.
| pub fn with_user_fee_increase(mut self, user_fee_increase: UserFeeIncrease) -> Self { | ||
| self.user_fee_increase = Some(user_fee_increase); | ||
| if let Some(settings) = self.settings.as_mut() { | ||
| settings.user_fee_increase = Some(user_fee_increase); | ||
| } | ||
| self | ||
| } | ||
|
|
||
| /// Adds settings to the document delete transition | ||
| /// Adds settings to the document delete transition. | ||
| /// | ||
| /// Explicit setters always win regardless of call order: if | ||
| /// [`Self::with_user_fee_increase`] or | ||
| /// [`Self::with_state_transition_creation_options`] has already been | ||
| /// called on this builder, the corresponding field in `settings` is | ||
| /// only used as a fallback when the dedicated builder field is still | ||
| /// `None`. This makes the builder order-independent for these two | ||
| /// fields and avoids silently clobbering a deliberate caller choice. | ||
| /// | ||
| /// After this call, the stored [`PutSettings`] reflects the | ||
| /// **effective** values for `user_fee_increase` and | ||
| /// `state_transition_creation_options` — i.e. whichever value will | ||
| /// actually be applied at sign time — so `self.settings` and the | ||
| /// dedicated builder fields do not disagree. Other [`PutSettings`] | ||
| /// fields (timeouts, retry behavior, etc.) are preserved from the | ||
| /// supplied `settings`. | ||
| /// | ||
| /// # Arguments | ||
| /// | ||
| /// * `settings` - The settings to add | ||
| /// | ||
| /// # Returns | ||
| /// | ||
| /// * `Self` - The updated builder | ||
| pub fn with_settings(mut self, settings: PutSettings) -> Self { | ||
| self.settings = Some(settings); | ||
| if self.user_fee_increase.is_none() { | ||
| if let Some(user_fee_increase) = settings.user_fee_increase { | ||
| self.user_fee_increase = Some(user_fee_increase); | ||
| } | ||
| } | ||
| if self.state_transition_creation_options.is_none() { | ||
| if let Some(state_transition_creation_options) = | ||
| settings.state_transition_creation_options | ||
| { | ||
| self.state_transition_creation_options = Some(state_transition_creation_options); | ||
| } | ||
| } | ||
| // Merge effective values back into the stored settings so there is | ||
| // exactly one source of truth for `user_fee_increase` and | ||
| // `state_transition_creation_options`. Other PutSettings fields | ||
| // (timeouts, retry behavior, etc.) are preserved from the supplied | ||
| // `settings`. | ||
| let mut effective_settings = settings; | ||
| effective_settings.user_fee_increase = self.user_fee_increase; | ||
| effective_settings.state_transition_creation_options = | ||
| self.state_transition_creation_options; | ||
| self.settings = Some(effective_settings); | ||
| self | ||
| } | ||
|
|
||
| /// Adds creation_options to the document delete transition | ||
| /// Adds creation_options to the document delete transition. | ||
| /// | ||
| /// Explicit setters always win regardless of call order. If | ||
| /// [`Self::with_settings`] has already stored a [`PutSettings`] on this | ||
| /// builder, its `state_transition_creation_options` is updated in | ||
| /// place so `self.settings` remains the single source of truth for | ||
| /// the effective value used at sign/broadcast time. Other | ||
| /// [`PutSettings`] fields are preserved. | ||
| /// | ||
| /// # Arguments | ||
| /// | ||
| /// * `creation_options` - The creation options to add | ||
| /// | ||
| /// # Returns | ||
| /// | ||
| /// * `Self` - The updated builder | ||
| pub fn with_state_transition_creation_options( | ||
| mut self, | ||
| creation_options: StateTransitionCreationOptions, | ||
| ) -> Self { | ||
| self.state_transition_creation_options = Some(creation_options); | ||
| if let Some(settings) = self.settings.as_mut() { | ||
| settings.state_transition_creation_options = Some(creation_options); | ||
| } | ||
| self | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: DocumentDeleteTransitionBuilder keeps user_fee_increase / state_transition_creation_options in two places
Both self.user_fee_increase / self.state_transition_creation_options and self.settings.{user_fee_increase, state_transition_creation_options} must be kept in sync, and all three setters (with_user_fee_increase, with_state_transition_creation_options, with_settings) now carry reconciliation logic to preserve 'explicit setter wins'. Tests cover every call order today, but a future contributor extending with_settings or adding a third overlapping field is likely to miss the sync requirement. Consider storing these only on self.settings (or only on the dedicated fields) and computing the effective value at sign time so there is a single source of truth.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 120-204: DocumentDeleteTransitionBuilder keeps user_fee_increase / state_transition_creation_options in two places
Both `self.user_fee_increase` / `self.state_transition_creation_options` and `self.settings.{user_fee_increase, state_transition_creation_options}` must be kept in sync, and all three setters (`with_user_fee_increase`, `with_state_transition_creation_options`, `with_settings`) now carry reconciliation logic to preserve 'explicit setter wins'. Tests cover every call order today, but a future contributor extending `with_settings` or adding a third overlapping field is likely to miss the sync requirement. Consider storing these only on `self.settings` (or only on the dedicated fields) and computing the effective value at sign time so there is a single source of truth.
| #[wasm_bindgen(js_name = "prepareDocumentCreate")] | ||
| pub async fn prepare_document_create( | ||
| &self, | ||
| options: PrepareDocumentCreateOptionsJs, | ||
| ) -> Result<StateTransitionWasm, WasmSdkError> { | ||
| // Extract document from options | ||
| let document_wasm = DocumentWasm::try_from_options(&options, "document")?; | ||
| let document: Document = document_wasm.clone().into(); | ||
|
|
||
| ensure_document_create_revision(document.revision(), "prepareDocumentReplace")?; | ||
|
|
||
| // Get metadata from document | ||
| let contract_id: Identifier = document_wasm.data_contract_id().into(); | ||
| let document_type_name = document_wasm.document_type_name(); | ||
|
|
||
| // Get entropy from document | ||
| let entropy = document_wasm.entropy().ok_or_else(|| { | ||
| WasmSdkError::invalid_argument("Document must have entropy set for creation") | ||
| })?; | ||
|
|
||
| if entropy.len() != 32 { | ||
| return Err(WasmSdkError::invalid_argument( | ||
| "Document entropy must be exactly 32 bytes", | ||
| )); | ||
| } | ||
|
|
||
| let mut entropy_array = [0u8; 32]; | ||
| entropy_array.copy_from_slice(&entropy); | ||
|
|
||
| // Extract identity key from options | ||
| let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; | ||
| let identity_key: IdentityPublicKey = identity_key_wasm.into(); | ||
|
|
||
| // Extract signer from options | ||
| let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; | ||
|
|
||
| // Fetch the data contract (using cache) | ||
| let data_contract = self.get_or_fetch_contract(contract_id).await?; | ||
|
|
||
| // Get document type (owned) | ||
| let document_type = get_document_type(&data_contract, &document_type_name)?; | ||
|
|
||
| // Extract settings from options | ||
| let settings = | ||
| try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into); | ||
| let token_payment_info = try_from_options_optional_token_payment_info(&options)?; | ||
|
|
||
| // Build, sign, and structurally validate the state transition without | ||
| // broadcasting it. Local pre-broadcast failures are rolled back inside | ||
| // rs-sdk so the identity-contract nonce cache cannot advance past a | ||
| // nonce the network never observed. | ||
| let state_transition = build_signed_document_create_transition( | ||
| self.inner_sdk(), | ||
| &document, | ||
| &document_type, | ||
| entropy_array, | ||
| &identity_key, | ||
| token_payment_info, | ||
| &signer, | ||
| settings, | ||
| ) | ||
| .await?; | ||
|
|
||
| Ok(state_transition.into()) | ||
| } | ||
| } | ||
|
|
||
| // ============================================================================ | ||
| // Prepare Document Replace (Two-Phase API) | ||
| // ============================================================================ | ||
|
|
||
| /// TypeScript interface for prepare document replace options | ||
| #[wasm_bindgen(typescript_custom_section)] | ||
| const PREPARE_DOCUMENT_REPLACE_OPTIONS_TS: &'static str = r#" | ||
| /** | ||
| * Options for preparing a document replace state transition without broadcasting. | ||
| * | ||
| * Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern. | ||
| */ | ||
| export interface PrepareDocumentReplaceOptions { | ||
| /** The document with updated data (same ID, incremented revision). */ | ||
| document: Document; | ||
| /** The identity public key to use for signing. */ | ||
| identityKey: IdentityPublicKey; | ||
| /** Signer containing the private key for the identity key. */ | ||
| signer: IdentitySigner; | ||
| /** Optional token payment agreement for document types with tokenCost.replace. */ | ||
| tokenPaymentInfo?: DocumentTokenPaymentInfo; | ||
| /** Optional settings (retries, timeouts, userFeeIncrease). */ | ||
| settings?: PutSettings; | ||
| } | ||
| "#; | ||
|
|
||
| #[wasm_bindgen] | ||
| extern "C" { | ||
| #[wasm_bindgen(typescript_type = "PrepareDocumentReplaceOptions")] | ||
| pub type PrepareDocumentReplaceOptionsJs; | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| impl WasmSdk { | ||
| /// Prepare a document replace state transition without broadcasting. | ||
| /// | ||
| /// This method handles nonce management, ST construction, and signing, but does | ||
| /// **not** broadcast or wait for a response. See `prepareDocumentCreate` for | ||
| /// the full two-phase usage pattern. | ||
| /// | ||
| /// **Nonce consumption:** A successful call advances this SDK instance's local | ||
| /// identity-contract nonce cache and embeds that nonce in the signed transition. | ||
| /// Platform state is not mutated until broadcast/processing. Only call this when | ||
| /// you intend to broadcast / persist-and-retry the returned transition. See module | ||
| /// docs for details. | ||
| /// | ||
| /// @param options - Replace options including document, identity key, and signer | ||
| /// @returns The signed StateTransition ready for broadcasting | ||
| #[wasm_bindgen(js_name = "prepareDocumentReplace")] | ||
| pub async fn prepare_document_replace( | ||
| &self, | ||
| options: PrepareDocumentReplaceOptionsJs, | ||
| ) -> Result<StateTransitionWasm, WasmSdkError> { | ||
| // Extract document from options | ||
| let document_wasm = DocumentWasm::try_from_options(&options, "document")?; | ||
| let document: Document = document_wasm.clone().into(); | ||
|
|
||
| ensure_document_replace_revision(document.revision(), "prepareDocumentCreate")?; | ||
|
|
||
| // Get metadata from document | ||
| let contract_id: Identifier = document_wasm.data_contract_id().into(); | ||
| let document_type_name = document_wasm.document_type_name(); | ||
|
|
||
| // Extract identity key from options | ||
| let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; | ||
| let identity_key: IdentityPublicKey = identity_key_wasm.into(); | ||
|
|
||
| // Extract signer from options | ||
| let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; | ||
|
|
||
| // Fetch the data contract (using cache) | ||
| let data_contract = self.get_or_fetch_contract(contract_id).await?; | ||
|
|
||
| // Get document type (owned) | ||
| let document_type = get_document_type(&data_contract, &document_type_name)?; | ||
|
|
||
| // Extract settings from options | ||
| let settings = | ||
| try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into); | ||
| let token_payment_info = try_from_options_optional_token_payment_info(&options)?; | ||
|
|
||
| // Build, sign, and structurally validate the state transition without | ||
| // broadcasting it. Local pre-broadcast failures are rolled back inside | ||
| // rs-sdk so the identity-contract nonce cache cannot advance past a | ||
| // nonce the network never observed. | ||
| let state_transition = build_signed_document_replace_transition( | ||
| self.inner_sdk(), | ||
| &document, | ||
| &document_type, | ||
| &identity_key, | ||
| token_payment_info, | ||
| &signer, | ||
| settings, | ||
| ) | ||
| .await?; | ||
|
|
||
| Ok(state_transition.into()) | ||
| } | ||
| } | ||
|
|
||
| // ============================================================================ | ||
| // Prepare Document Delete (Two-Phase API) | ||
| // ============================================================================ | ||
|
|
||
| /// TypeScript interface for prepare document delete options | ||
| #[wasm_bindgen(typescript_custom_section)] | ||
| const PREPARE_DOCUMENT_DELETE_OPTIONS_TS: &'static str = r#" | ||
| /** | ||
| * Options for preparing a document delete state transition without broadcasting. | ||
| * | ||
| * Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern. | ||
| */ | ||
| export interface PrepareDocumentDeleteOptions { | ||
| /** | ||
| * The document to delete — either a Document instance or an object with identifiers. | ||
| */ | ||
| document: Document | { | ||
| id: IdentifierLike; | ||
| ownerId: IdentifierLike; | ||
| dataContractId: IdentifierLike; | ||
| documentTypeName: string; | ||
| }; | ||
| /** The identity public key to use for signing. */ | ||
| identityKey: IdentityPublicKey; | ||
| /** Signer containing the private key for the identity key. */ | ||
| signer: IdentitySigner; | ||
| /** Optional token payment agreement for document types with tokenCost.delete. */ | ||
| tokenPaymentInfo?: DocumentTokenPaymentInfo; | ||
| /** Optional settings (retries, timeouts, userFeeIncrease). */ | ||
| settings?: PutSettings; | ||
| } | ||
| "#; | ||
|
|
||
| #[wasm_bindgen] | ||
| extern "C" { | ||
| #[wasm_bindgen(typescript_type = "PrepareDocumentDeleteOptions")] | ||
| pub type PrepareDocumentDeleteOptionsJs; | ||
| } | ||
|
|
||
| #[wasm_bindgen] | ||
| impl WasmSdk { | ||
| /// Prepare a document delete state transition without broadcasting. | ||
| /// | ||
| /// This method handles nonce management, ST construction, and signing, but does | ||
| /// **not** broadcast or wait for a response. See `prepareDocumentCreate` for | ||
| /// the full two-phase usage pattern. | ||
| /// | ||
| /// **Nonce consumption:** A successful call advances this SDK instance's local | ||
| /// identity-contract nonce cache and embeds that nonce in the signed transition. | ||
| /// Platform state is not mutated until broadcast/processing. Only call this when | ||
| /// you intend to broadcast / persist-and-retry the returned transition. See module | ||
| /// docs for details. | ||
| /// | ||
| /// @param options - Delete options including document identifiers, identity key, and signer | ||
| /// @returns The signed StateTransition ready for broadcasting | ||
| #[wasm_bindgen(js_name = "prepareDocumentDelete")] | ||
| pub async fn prepare_document_delete( | ||
| &self, | ||
| options: PrepareDocumentDeleteOptionsJs, | ||
| ) -> Result<StateTransitionWasm, WasmSdkError> { | ||
| // Extract document field - can be either a Document instance or plain object | ||
| let document_js = js_sys::Reflect::get(&options, &JsValue::from_str("document")) | ||
| .map_err(|_| WasmSdkError::invalid_argument("document is required"))?; | ||
|
|
||
| if document_js.is_undefined() || document_js.is_null() { | ||
| return Err(WasmSdkError::invalid_argument("document is required")); | ||
| } | ||
|
|
||
| // Check if it's a Document instance or a plain object with fields | ||
| let (document_id, owner_id, contract_id, document_type_name): ( | ||
| Identifier, | ||
| Identifier, | ||
| Identifier, | ||
| String, | ||
| ) = if get_class_type(&document_js).ok().as_deref() == Some("Document") { | ||
| let doc: DocumentWasm = document_js | ||
| .to_wasm::<DocumentWasm>("Document") | ||
| .map(|boxed| (*boxed).clone())?; | ||
| let doc_inner: Document = doc.clone().into(); | ||
| ( | ||
| doc.id().into(), | ||
| doc_inner.owner_id(), | ||
| doc.data_contract_id().into(), | ||
| doc.document_type_name(), | ||
| ) | ||
| } else { | ||
| ( | ||
| IdentifierWasm::try_from_options(&document_js, "id")?.into(), | ||
| IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(), | ||
| IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(), | ||
| try_from_options_with(&document_js, "documentTypeName", |v| { | ||
| try_to_string(v, "documentTypeName") | ||
| })?, | ||
| ) | ||
| }; | ||
|
|
||
| // Extract identity key from options | ||
| let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; | ||
| let identity_key: IdentityPublicKey = identity_key_wasm.into(); | ||
|
|
||
| // Extract signer from options | ||
| let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; | ||
|
|
||
| // Fetch the data contract (using cache) | ||
| let data_contract = self.get_or_fetch_contract(contract_id).await?; | ||
|
|
||
| // Extract settings from options | ||
| let settings = | ||
| try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into); | ||
| let token_payment_info = try_from_options_optional_token_payment_info(&options)?; | ||
|
|
||
| // Build the delete transition using the builder's sign method (which does NOT broadcast) | ||
| let builder = DocumentDeleteTransitionBuilder::new( | ||
| Arc::new(data_contract), | ||
| document_type_name, | ||
| document_id, | ||
| owner_id, | ||
| ); | ||
|
|
||
| let builder = if let Some(token_payment_info) = token_payment_info { | ||
| builder.with_token_payment_info(token_payment_info) | ||
| } else { | ||
| builder | ||
| }; | ||
|
|
||
| let builder = if let Some(s) = settings { | ||
| builder.with_settings(s) | ||
| } else { | ||
| builder | ||
| }; | ||
|
|
||
| // Delegate the nonce-allocate / sign / structure-validate / rollback | ||
| // sequence to rs-sdk's shared helper so wasm-sdk and FFI share the | ||
| // single implementation. | ||
| let state_transition = build_signed_document_delete_transition( | ||
| self.inner_sdk(), | ||
| &builder, | ||
| &identity_key, | ||
| &signer, | ||
| ) | ||
| .await?; | ||
|
|
||
| Ok(state_transition.into()) |
There was a problem hiding this comment.
🟡 Suggestion: New prepareDocument WASM entry points have no wrapper-level tests*
The three prepareDocument* methods contain non-trivial wrapper logic — JS option extraction (Reflect::get + unchecked_into), entropy length validation, the Document-vs-plain-object split for delete, and conversion back into StateTransitionWasm — that is invisible to Rust's type system at the JsValue boundary. The current tests in this file cover only pure revision guards and an unrelated structure-validation helper, and tests/unit/prepare-document.spec.ts only validates argument-shape errors. Add at least one wrapper-level test per prepare method (mismatched entropy/id, bad entropy length, invalid options object) so regressions in field naming, branch selection, or error mapping fail loudly.
source: ['codex', 'claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 613-922: New prepareDocument* WASM entry points have no wrapper-level tests
The three `prepareDocument*` methods contain non-trivial wrapper logic — JS option extraction (`Reflect::get` + `unchecked_into`), entropy length validation, the `Document`-vs-plain-object split for delete, and conversion back into `StateTransitionWasm` — that is invisible to Rust's type system at the `JsValue` boundary. The current tests in this file cover only pure revision guards and an unrelated structure-validation helper, and `tests/unit/prepare-document.spec.ts` only validates argument-shape errors. Add at least one wrapper-level test per prepare method (mismatched entropy/id, bad entropy length, invalid options object) so regressions in field naming, branch selection, or error mapping fail loudly.
| pub async fn build_signed_document_replace_transition<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| ensure_revision_for_replace(document.revision())?; | ||
| build_signed_document_create_or_replace_transition_legacy( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| None, // entropy is unused on the replace path | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| ) | ||
| .await | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Replace-side pre-broadcast rollback is not directly exercised by tests
build_signed_document_replace_transition is a new public strict entry point with its own revision guard, but the test module only proves rollback behavior for the create helper. The replace branch of the revision-driven dispatcher and its rollback path are unexercised — a future change to is_document_replace_revision, the replace wrapper, or the shared legacy dispatch could break rollback or accidentally route replaces through the create branch while the existing create-focused tests still pass. Add a mock-based async test for a replace transition that fails after nonce allocation and asserts the next allocation reuses the rolled-back value.
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 392-413: Replace-side pre-broadcast rollback is not directly exercised by tests
`build_signed_document_replace_transition` is a new public strict entry point with its own revision guard, but the test module only proves rollback behavior for the create helper. The replace branch of the revision-driven dispatcher and its rollback path are unexercised — a future change to `is_document_replace_revision`, the replace wrapper, or the shared legacy dispatch could break rollback or accidentally route replaces through the create branch while the existing create-focused tests still pass. Add a mock-based async test for a replace transition that fails after nonce allocation and asserts the next allocation reuses the rolled-back value.
| fn is_document_replace_revision(revision: Option<u64>) -> bool { | ||
| revision.is_some_and(|rev| rev > INITIAL_REVISION) | ||
| } | ||
|
|
||
| /// Reject documents whose revision is `Some(0)` for the create-or-replace | ||
| /// dispatch helper. Both create and replace require non-zero revisions, so | ||
| /// `0` is always invalid regardless of caller intent. | ||
| fn ensure_revision_nonzero(revision: Option<u64>) -> Result<(), Error> { | ||
| if matches!(revision, Some(0)) { | ||
| return Err(Error::InvalidArgument( | ||
| "document revision 0 is invalid; \ | ||
| use unset or 1 (INITIAL_REVISION) for create, or > 1 for replace" | ||
| .to_string(), | ||
| )); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Strict revision guard for the document **create** path. | ||
| /// | ||
| /// Accepts `None` and `Some(INITIAL_REVISION)`. Rejects `Some(0)` and any | ||
| /// revision strictly greater than `INITIAL_REVISION`. This is the rs-sdk-side | ||
| /// fail-fast equivalent of the wasm-sdk `ensureDocumentCreateRevision` guard. | ||
| fn ensure_revision_for_create(revision: Option<u64>) -> Result<(), Error> { | ||
| match revision { | ||
| None => Ok(()), | ||
| Some(rev) if rev == INITIAL_REVISION => Ok(()), | ||
| Some(rev) => Err(Error::InvalidArgument(format!( | ||
| "document revision is {rev} but create requires revision \ | ||
| to be unset or {INITIAL_REVISION}; use the replace path for revisions > {INITIAL_REVISION}" | ||
| ))), | ||
| } | ||
| } | ||
|
|
||
| /// Strict revision guard for the document **replace** path. | ||
| /// | ||
| /// Accepts only `Some(rev)` with `rev > INITIAL_REVISION`. Rejects `None`, | ||
| /// `Some(0)`, and `Some(INITIAL_REVISION)`. This is the rs-sdk-side fail-fast | ||
| /// equivalent of the wasm-sdk `ensureDocumentReplaceRevision` guard. | ||
| fn ensure_revision_for_replace(revision: Option<u64>) -> Result<(), Error> { | ||
| match revision { | ||
| Some(rev) if rev > INITIAL_REVISION => Ok(()), | ||
| Some(rev) => Err(Error::InvalidArgument(format!( | ||
| "document revision is {rev} but replace requires revision > \ | ||
| {INITIAL_REVISION}; use the create path for new documents" | ||
| ))), | ||
| None => Err(Error::InvalidArgument( | ||
| "document must have a revision set for replace; \ | ||
| use the create path for new documents" | ||
| .to_string(), | ||
| )), | ||
| } | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Four overlapping revision predicates complicate the create/replace control flow
The module exposes four revision helpers — is_document_replace_revision, ensure_revision_nonzero, ensure_revision_for_create, ensure_revision_for_replace — and the strict wrappers run their own check, delegate to _legacy which runs ensure_revision_nonzero again, which routes through build_and_sign_create_or_replace_after_nonce that re-tests via is_document_replace_revision. The redundant checks are harmless (the stricter ones subsume the weaker), but a reader has to trace every branch to convince themselves Some(0) can never land in the create branch of the inner builder. Consider collapsing to a single RevisionIntent::{Create, Replace} enum produced at the entry point, or dropping ensure_revision_nonzero from _legacy now that both strict public wrappers cover its job.
source: ['claude']
| #[allow(clippy::too_many_arguments)] | ||
| #[deprecated( | ||
| note = "use build_signed_document_create_transition or build_signed_document_replace_transition for strict intent validation" | ||
| )] | ||
| pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| document_state_transition_entropy: Option<[u8; 32]>, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| build_signed_document_create_or_replace_transition_legacy( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| ) | ||
| .await | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Public deprecated dispatcher adds API surface with no documented in-tree caller
build_signed_document_create_or_replace_transition is introduced as a public function in this PR and immediately marked #[deprecated], with the actual logic in private _legacy. The doc says it's retained 'for source compatibility with downstream native callers that depended on it before the strict helpers were introduced', but git log shows this function name is new in this PR — there is no pre-existing public API to be source-compatible with. If no out-of-tree consumer already imports this exact name, drop the public wrapper and keep _legacy private. If a specific external consumer needs it (e.g. dash-evo-tool), name them in the doc comment so the deprecation has clear scope.
source: ['claude']
| @@ -0,0 +1,347 @@ | |||
| import { expect } from './helpers/chai.ts'; | |||
There was a problem hiding this comment.
💬 Nitpick: prepareDocumentCreate entropy/id mismatch not exercised through the wasm boundary
build_signed_document_create_transition has a Rust unit test verifying the id-matches-entropy guard fires before nonce allocation, but tests/unit/prepare-document.spec.ts only validates argument-shape errors and does not assert the entropy/id-mismatch path surfaced through the wasm wrapper. A small test that builds a Document with mismatched id and entropy, calls prepareDocumentCreate, and asserts the resulting WasmSdkError has kind InvalidArgument with a 'does not match' message would lock in the user-visible behavior across the wasm boundary.
source: ['claude']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR adds strict prepare/build helpers for document state transitions in rs-sdk and exposes prepare* APIs in wasm-sdk, with careful nonce allocate/rollback semantics. No blockers verified against the worktree. The main feedback areas: a brand-new public function shipped already #[deprecated], internal validation module visibility widened solely for a sibling-crate test, an undocumented behavior change in DocumentDeleteTransitionBuilder::with_settings, and the older Sdk::document_create/document_replace builder paths still bypass the new strict validation. Note: codex's blocking finding about Sdk::version() falling back to latest() (sdk.rs:1054-1059) was verified against the PR diff and is pre-existing code untouched by this PR — dropped as out of scope.
Reviewed commit: ff2285b
🟡 6 suggestion(s) | 💬 4 nitpick(s)
3 additional findings
🟡 suggestion: `Sdk::document_create` / `Sdk::document_replace` bypass the new strict pre-broadcast validation
packages/rs-sdk/src/platform/documents/transitions/create.rs (lines 250-271)
Both Sdk::document_create (packages/rs-sdk/src/platform/documents/transitions/create.rs:250-271) and Sdk::document_replace (packages/rs-sdk/src/platform/documents/transitions/replace.rs:261-270) sign via DocumentCreateTransitionBuilder::sign() / DocumentReplaceTransitionBuilder::sign() and broadcast immediately. Those builder paths never call ensure_valid_state_transition_structure and never reuse the new build_signed_document_create_transition / build_signed_document_replace_transition helpers. As a result, Rust callers using the builder API still get the old behavior: id/entropy mismatches and other locally-detectable structural problems are not rejected as InvalidArgument before broadcast, and the strict revision-vs-intent boundary is not enforced. The public rs-sdk now exposes two different reliability models for the same operation depending on which entry point the caller picks. Either route the builder path through the strict helpers, or document the divergence explicitly so callers know which API gives fail-fast structural validation.
🟡 suggestion: documentCreate now implicitly enforces id-matches-entropy at the JS/Rust boundary, but migration notes do not mention it
packages/wasm-sdk/src/state_transitions/document.rs (lines 217-278)
After this PR, documentCreate always forwards Some(entropy_array) into a path that calls ensure_document_id_matches_entropy and rejects with InvalidArgument when document.id != generate_document_id_v0(contract_id, owner_id, type_name, entropy). However, DocumentWasm::constructor (in wasm-dpp2) still permits JS callers to supply id and entropy independently — only when id is omitted does it derive the id from entropy. JS code that previously constructed a Document with an explicit id plus a non-matching entropy (which the old create path tolerated) will now fail at documentCreate/prepareDocumentCreate time. The PR description only calls out revision tightening for the JS-facing migration. Either (a) add a matching note to the wasm-sdk migration text and the DocumentCreateOptions / PrepareDocumentCreateOptions TS docstrings, or (b) validate the id-vs-entropy invariant in DocumentWasm::constructor so the failure surfaces at construction time.
🟡 suggestion: High-level `tokenPaymentInfo` parsing does not accept existing `TokenPaymentInfo` wasm class instances
packages/wasm-sdk/src/state_transitions/document.rs (lines 126-147)
try_from_options_optional_token_payment_info always rebuilds tokenPaymentInfo via TokenPaymentInfoWasm::constructor(...), which expects a plain TokenPaymentInfoOptions object and uses serde_wasm_bindgen::from_value(...) to deserialize primitive fields. Meanwhile the lower-level document transition bindings in wasm-dpp2 advertise tokenPaymentInfo?: TokenPaymentInfo (the class) — see packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs:24-28. The high-level wasm-sdk APIs do advertise a different TS type (DocumentTokenPaymentInfo, a plain options bag) so this is by design, but it's an interop inconsistency between the two JS-facing layers: a JS caller who reuses a new TokenPaymentInfo(...) instance with the high-level API will get a confusing InvalidArgument instead of the natural reuse path. Detect a wasm class instance and route it through TokenPaymentInfoWasm::try_from(&value) before falling back to the options-bag constructor.
💡 Suggested change
let token_payment_info = if get_class_type(&token_payment_info_value)
.ok()
.as_deref()
== Some("TokenPaymentInfo")
{
TokenPaymentInfoWasm::try_from(&token_payment_info_value)
} else {
TokenPaymentInfoWasm::constructor(
token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(),
)
}
.map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?;
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 250-271: `Sdk::document_create` / `Sdk::document_replace` bypass the new strict pre-broadcast validation
Both `Sdk::document_create` (`packages/rs-sdk/src/platform/documents/transitions/create.rs:250-271`) and `Sdk::document_replace` (`packages/rs-sdk/src/platform/documents/transitions/replace.rs:261-270`) sign via `DocumentCreateTransitionBuilder::sign()` / `DocumentReplaceTransitionBuilder::sign()` and broadcast immediately. Those builder paths never call `ensure_valid_state_transition_structure` and never reuse the new `build_signed_document_create_transition` / `build_signed_document_replace_transition` helpers. As a result, Rust callers using the builder API still get the old behavior: id/entropy mismatches and other locally-detectable structural problems are not rejected as `InvalidArgument` before broadcast, and the strict revision-vs-intent boundary is not enforced. The public rs-sdk now exposes two different reliability models for the same operation depending on which entry point the caller picks. Either route the builder path through the strict helpers, or document the divergence explicitly so callers know which API gives fail-fast structural validation.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 146-160: `with_settings` now changes the effective `user_fee_increase` paid on the wire
Before this PR, `DocumentDeleteTransitionBuilder::with_settings(settings)` just stored `settings` on `self.settings`; the sign path read `self.user_fee_increase.unwrap_or_default()` and `self.state_transition_creation_options` (both defaulting to `None`), so any values passed via `PutSettings` were silently dropped at sign time. With this PR, `with_settings` now extracts those two fields into the dedicated builder fields when they are still `None`. The fix is correct, but it changes the on-chain fee that some existing native callers pay (previously `0`/default, now whatever they intended to pass). The PR description only mentions the wasm-sdk revision routing compat note; this delete-side fee behavior change is not surfaced. Add a release-note entry so downstream rs-sdk consumers know that the `user_fee_increase` they pass via `with_settings(...).sign(...)` may now actually take effect.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 515-538: `put_to_platform` create path clones the document twice
The rerouted `PutDocument::put_to_platform` create branch calls `resolve_document_create_entropy(self, &document_type, document_state_transition_entropy)` which clones `self` unconditionally. It then hands `&resolved_document` and `Some(resolved_entropy)` to `build_signed_document_create_transition`, which routes through `_legacy` → `build_and_sign_create_or_replace_after_nonce`, which calls `resolve_document_create_entropy(document, document_type, Some(entropy))` *again* — and because the entropy is now `Some`, that call also takes the `Some` branch and returns `(document.clone(), entropy)`. End result: two clones of the document per create call on the legacy trait path, where the previous inline implementation cloned at most once. `Document::properties` can be a sizeable `BTreeMap<String, Value>`, so this is an avoidable regression for callers still using the trait method (notably `rs-platform-wallet`). Simplest fix: skip the outer `resolve_document_create_entropy` and hand the document + original entropy directly to the strict create helper (or to `_legacy`).
- [SUGGESTION] lines 119-143: `ensure_document_id_matches_entropy` is hard-pinned to `generate_document_id_v0`
The id-matches-entropy guard derives the expected id via `Document::generate_document_id_v0` only. This matches the current `resolve_document_create_entropy` helper, but document id derivation is otherwise versioned in dpp (`v0` suffix). If a future protocol version introduces a `generate_document_id_v1` (or a versioned dispatcher), this guard will silently reject documents whose ids are derived under the new scheme even though the transition build itself succeeds. Centralize the id-derivation behind a single versioned dispatch (or at least keep the two callsites adjacent, well-commented, and grep-able) so the next migration updates both at once.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 217-278: documentCreate now implicitly enforces id-matches-entropy at the JS/Rust boundary, but migration notes do not mention it
After this PR, `documentCreate` always forwards `Some(entropy_array)` into a path that calls `ensure_document_id_matches_entropy` and rejects with `InvalidArgument` when `document.id != generate_document_id_v0(contract_id, owner_id, type_name, entropy)`. However, `DocumentWasm::constructor` (in `wasm-dpp2`) still permits JS callers to supply `id` and `entropy` independently — only when `id` is omitted does it derive the id from entropy. JS code that previously constructed a `Document` with an explicit `id` plus a non-matching `entropy` (which the old create path tolerated) will now fail at `documentCreate`/`prepareDocumentCreate` time. The PR description only calls out revision tightening for the JS-facing migration. Either (a) add a matching note to the wasm-sdk migration text and the `DocumentCreateOptions` / `PrepareDocumentCreateOptions` TS docstrings, or (b) validate the id-vs-entropy invariant in `DocumentWasm::constructor` so the failure surfaces at construction time.
- [SUGGESTION] lines 126-147: High-level `tokenPaymentInfo` parsing does not accept existing `TokenPaymentInfo` wasm class instances
`try_from_options_optional_token_payment_info` always rebuilds `tokenPaymentInfo` via `TokenPaymentInfoWasm::constructor(...)`, which expects a plain `TokenPaymentInfoOptions` object and uses `serde_wasm_bindgen::from_value(...)` to deserialize primitive fields. Meanwhile the lower-level document transition bindings in wasm-dpp2 advertise `tokenPaymentInfo?: TokenPaymentInfo` (the class) — see `packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs:24-28`. The high-level wasm-sdk APIs do advertise a different TS type (`DocumentTokenPaymentInfo`, a plain options bag) so this is by design, but it's an interop inconsistency between the two JS-facing layers: a JS caller who reuses a `new TokenPaymentInfo(...)` instance with the high-level API will get a confusing `InvalidArgument` instead of the natural reuse path. Detect a wasm class instance and route it through `TokenPaymentInfoWasm::try_from(&value)` before falling back to the options-bag constructor.
| #[allow(clippy::too_many_arguments)] | ||
| #[deprecated( | ||
| note = "use build_signed_document_create_transition or build_signed_document_replace_transition for strict intent validation" | ||
| )] | ||
| pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| document_state_transition_entropy: Option<[u8; 32]>, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| build_signed_document_create_or_replace_transition_legacy( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| ) | ||
| .await | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Brand-new pub function shipped already #[deprecated]
build_signed_document_create_or_replace_transition is introduced in this PR and is simultaneously annotated #[deprecated]. The doc comment justifies it as preserving "source compatibility with downstream native callers that depended on it before the strict helpers were introduced," but there are no such pre-existing callers — the function did not exist before this PR, and in-crate code routes through the private _legacy variant to avoid the deprecation warning. Shipping a public function in a deprecated state on its first release emits a deprecation warning on any first-time consumer for no observable migration benefit. Either drop the #[deprecated] attribute (and treat it purely as the legacy dispatch entry point) or remove the public function entirely and have callers compose the two strict helpers themselves.
source: ['claude', 'codex']
| pub mod unshield; | ||
| pub mod update_price_of_document; | ||
| pub(crate) mod validation; | ||
| pub mod validation; |
There was a problem hiding this comment.
💬 Nitpick: validation module visibility widened from pub(crate) to pub solely for a wasm-sdk test
The only consumer that requires the wider visibility is packages/wasm-sdk/src/state_transitions/document.rs:1353, which imports ensure_valid_state_transition_structure inside a #[cfg(test)] regression test. Promoting an implementation-detail helper to a permanent public-API guarantee for a downstream test widens the API surface for the wrong reason: any future change to that helper's signature is now semver-affecting. Prefer either (a) moving the regression test into rs-sdk where the helper can stay pub(crate), or (b) re-exposing the helper via a #[cfg(any(test, feature = "test-utils"))]-gated module so the relaxation is opt-in for test configurations only.
source: ['claude', 'codex']
| pub fn with_settings(mut self, mut settings: PutSettings) -> Self { | ||
| if self.user_fee_increase.is_none() { | ||
| self.user_fee_increase = settings.user_fee_increase; | ||
| } | ||
| if self.state_transition_creation_options.is_none() { | ||
| self.state_transition_creation_options = settings.state_transition_creation_options; | ||
| } | ||
| // Strip the fee/creation-options fields from the stored settings so | ||
| // the dedicated builder fields are the sole source of truth at | ||
| // sign time. The remainder of `settings` flows through unchanged. | ||
| settings.user_fee_increase = None; | ||
| settings.state_transition_creation_options = None; | ||
| self.settings = Some(settings); | ||
| self | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: with_settings now changes the effective user_fee_increase paid on the wire
Before this PR, DocumentDeleteTransitionBuilder::with_settings(settings) just stored settings on self.settings; the sign path read self.user_fee_increase.unwrap_or_default() and self.state_transition_creation_options (both defaulting to None), so any values passed via PutSettings were silently dropped at sign time. With this PR, with_settings now extracts those two fields into the dedicated builder fields when they are still None. The fix is correct, but it changes the on-chain fee that some existing native callers pay (previously 0/default, now whatever they intended to pass). The PR description only mentions the wasm-sdk revision routing compat note; this delete-side fee behavior change is not surfaced. Add a release-note entry so downstream rs-sdk consumers know that the user_fee_increase they pass via with_settings(...).sign(...) may now actually take effect.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 146-160: `with_settings` now changes the effective `user_fee_increase` paid on the wire
Before this PR, `DocumentDeleteTransitionBuilder::with_settings(settings)` just stored `settings` on `self.settings`; the sign path read `self.user_fee_increase.unwrap_or_default()` and `self.state_transition_creation_options` (both defaulting to `None`), so any values passed via `PutSettings` were silently dropped at sign time. With this PR, `with_settings` now extracts those two fields into the dedicated builder fields when they are still `None`. The fix is correct, but it changes the on-chain fee that some existing native callers pay (previously `0`/default, now whatever they intended to pass). The PR description only mentions the wasm-sdk revision routing compat note; this delete-side fee behavior change is not surfaced. Add a release-note entry so downstream rs-sdk consumers know that the `user_fee_increase` they pass via `with_settings(...).sign(...)` may now actually take effect.
| ensure_revision_nonzero(document.revision())?; | ||
|
|
||
| let owner_id = document.owner_id(); | ||
| let contract_id = document_type.data_contract_id(); | ||
| let new_identity_contract_nonce = sdk | ||
| .get_identity_contract_nonce(owner_id, contract_id, true, settings) | ||
| .await?; | ||
|
|
||
| let result = build_and_sign_create_or_replace_after_nonce( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| new_identity_contract_nonce, | ||
| ) | ||
| .await; | ||
|
|
||
| match result { | ||
| Ok(transition) => Ok(transition), | ||
| Err(err) => { | ||
| sdk.rollback_identity_contract_nonce( | ||
| owner_id, | ||
| contract_id, | ||
| new_identity_contract_nonce, | ||
| ) | ||
| .await; | ||
| Err(err) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Revision is validated twice on the strict create/replace paths
On the strict create path, ensure_revision_for_create rejects anything other than None/Some(INITIAL_REVISION), and then the legacy dispatcher it forwards into runs ensure_revision_nonzero(document.revision()) again before allocating the nonce. Same for replace. put_to_platform then adds a third ensure_revision_nonzero call before branching into the strict helpers. The duplication is harmless but is dead defensive code that future maintainers must convince themselves are not subtly different guards. Consolidate the dispatch-level guard into the strict helpers (or skip the legacy dispatcher's pre-check when called from a strict helper) so there is one revision-guard codepath per intent.
source: ['claude', 'codex']
| let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) { | ||
| // Create path. Preserve legacy behavior: when the caller did not | ||
| // supply entropy, generate it and rewrite `document.id` so the | ||
| // pair stays consistent before we hand the (document, entropy) | ||
| // to the strict create helper. The strict helper still verifies | ||
| // that `document.id == generate_document_id_v0(entropy)` before | ||
| // allocating any nonce, so the legacy fallback cannot mask an | ||
| // id/entropy mismatch. | ||
| let (resolved_document, resolved_entropy) = resolve_document_create_entropy( | ||
| self, | ||
| &document_type, | ||
| document_state_transition_entropy, | ||
| ); | ||
| build_signed_document_create_transition( | ||
| sdk, | ||
| &resolved_document, | ||
| &document_type, | ||
| resolved_entropy, | ||
| &identity_public_key, | ||
| new_identity_contract_nonce, | ||
| settings.user_fee_increase.unwrap_or_default(), | ||
| token_payment_info, | ||
| signer, | ||
| sdk.version(), | ||
| settings.state_transition_creation_options, | ||
| settings, | ||
| ) | ||
| .await? |
There was a problem hiding this comment.
🟡 Suggestion: put_to_platform create path clones the document twice
The rerouted PutDocument::put_to_platform create branch calls resolve_document_create_entropy(self, &document_type, document_state_transition_entropy) which clones self unconditionally. It then hands &resolved_document and Some(resolved_entropy) to build_signed_document_create_transition, which routes through _legacy → build_and_sign_create_or_replace_after_nonce, which calls resolve_document_create_entropy(document, document_type, Some(entropy)) again — and because the entropy is now Some, that call also takes the Some branch and returns (document.clone(), entropy). End result: two clones of the document per create call on the legacy trait path, where the previous inline implementation cloned at most once. Document::properties can be a sizeable BTreeMap<String, Value>, so this is an avoidable regression for callers still using the trait method (notably rs-platform-wallet). Simplest fix: skip the outer resolve_document_create_entropy and hand the document + original entropy directly to the strict create helper (or to _legacy).
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 515-538: `put_to_platform` create path clones the document twice
The rerouted `PutDocument::put_to_platform` create branch calls `resolve_document_create_entropy(self, &document_type, document_state_transition_entropy)` which clones `self` unconditionally. It then hands `&resolved_document` and `Some(resolved_entropy)` to `build_signed_document_create_transition`, which routes through `_legacy` → `build_and_sign_create_or_replace_after_nonce`, which calls `resolve_document_create_entropy(document, document_type, Some(entropy))` *again* — and because the entropy is now `Some`, that call also takes the `Some` branch and returns `(document.clone(), entropy)`. End result: two clones of the document per create call on the legacy trait path, where the previous inline implementation cloned at most once. `Document::properties` can be a sizeable `BTreeMap<String, Value>`, so this is an avoidable regression for callers still using the trait method (notably `rs-platform-wallet`). Simplest fix: skip the outer `resolve_document_create_entropy` and hand the document + original entropy directly to the strict create helper (or to `_legacy`).
| fn ensure_document_create_revision( | ||
| revision: Option<u64>, | ||
| replace_api_name: &str, | ||
| ) -> Result<(), WasmSdkError> { | ||
| if let Some(revision) = revision { | ||
| if revision != INITIAL_REVISION { | ||
| return Err(WasmSdkError::invalid_argument(format!( | ||
| "Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.", | ||
| revision, INITIAL_REVISION, replace_api_name, | ||
| ))); | ||
| } | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| fn ensure_document_replace_revision( | ||
| revision: Option<u64>, | ||
| create_api_name: &str, | ||
| ) -> Result<(), WasmSdkError> { | ||
| let revision = revision.ok_or_else(|| { | ||
| WasmSdkError::invalid_argument(format!( | ||
| "Document must have a revision set for replace. Use {} for new documents.", | ||
| create_api_name, | ||
| )) | ||
| })?; | ||
|
|
||
| if revision <= INITIAL_REVISION { | ||
| return Err(WasmSdkError::invalid_argument(format!( | ||
| "Document revision is {} but replace requires revision > {}. Use {} for new documents.", | ||
| revision, INITIAL_REVISION, create_api_name, | ||
| ))); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Revision-0 error message points callers to the opposite API, which also rejects 0
ensure_document_create_revision(Some(0), "documentReplace") produces "Document revision is 0 but create requires revision to be unset or 1. Use documentReplace for existing documents.", but documentReplace / prepareDocumentReplace also reject revision 0 (via ensure_document_replace_revision, which directs the caller back to documentCreate). A user who follows either suggestion bounces between the two APIs. The strict rs-sdk-side helpers in put_document.rs use clearer wording. Unify the wasm-sdk helpers so revision 0 is rejected as "revision 0 is invalid; create requires unset or 1, replace requires > 1" rather than redirecting between sibling APIs that both reject it.
source: ['claude']
| fn ensure_document_id_matches_entropy( | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| entropy: &[u8; 32], | ||
| ) -> Result<(), Error> { | ||
| let expected = Document::generate_document_id_v0( | ||
| &document_type.data_contract_id(), | ||
| &document.owner_id(), | ||
| document_type.name(), | ||
| entropy.as_slice(), | ||
| ); | ||
| if document.id() != expected { | ||
| return Err(Error::InvalidArgument(format!( | ||
| "document.id does not match \ | ||
| generate_document_id_v0(contract_id, owner_id, document_type_name, entropy); \ | ||
| expected {expected}, got {got}. \ | ||
| Either set document.id to the derived value before calling \ | ||
| build_signed_document_create_transition, or use the legacy \ | ||
| PutDocument::put_to_platform trait method which auto-generates \ | ||
| entropy and rewrites the document id when entropy is None.", | ||
| got = document.id() | ||
| ))); | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: ensure_document_id_matches_entropy is hard-pinned to generate_document_id_v0
The id-matches-entropy guard derives the expected id via Document::generate_document_id_v0 only. This matches the current resolve_document_create_entropy helper, but document id derivation is otherwise versioned in dpp (v0 suffix). If a future protocol version introduces a generate_document_id_v1 (or a versioned dispatcher), this guard will silently reject documents whose ids are derived under the new scheme even though the transition build itself succeeds. Centralize the id-derivation behind a single versioned dispatch (or at least keep the two callsites adjacent, well-commented, and grep-able) so the next migration updates both at once.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 119-143: `ensure_document_id_matches_entropy` is hard-pinned to `generate_document_id_v0`
The id-matches-entropy guard derives the expected id via `Document::generate_document_id_v0` only. This matches the current `resolve_document_create_entropy` helper, but document id derivation is otherwise versioned in dpp (`v0` suffix). If a future protocol version introduces a `generate_document_id_v1` (or a versioned dispatcher), this guard will silently reject documents whose ids are derived under the new scheme even though the transition build itself succeeds. Centralize the id-derivation behind a single versioned dispatch (or at least keep the two callsites adjacent, well-commented, and grep-able) so the next migration updates both at once.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Two-phase prepare/broadcast document API in wasm-sdk plus shared pre-broadcast core in rs-sdk with nonce rollback is well-designed and well-tested. Verification confirms two real asymmetries between the create/replace builders and Sdk::document_create/document_replace one-shot paths: builder.sign() drops user_fee_increase/state_transition_creation_options set via with_settings, while the one-shot path overlays them. Several additional consistency, validation-ordering, and error-message issues exist. 4 lower-confidence findings dropped to fit budget.
Reviewed commit: 214dde3
🔴 2 blocking | 🟡 7 suggestion(s) | 💬 1 nitpick(s)
1 additional finding
🟡 suggestion: DocumentDeleteTransitionBuilder::sign_with_nonce skips ensure_valid_state_transition_structure (asymmetric with create/replace)
packages/rs-sdk/src/platform/documents/transitions/delete.rs (lines 237-281)
DocumentCreateTransitionBuilder::sign_with_nonce (create.rs:241) and DocumentReplaceTransitionBuilder::sign_with_nonce (replace.rs:218) both end with ensure_valid_state_transition_structure(&state_transition, platform_version)? before returning. DocumentDeleteTransitionBuilder::sign_with_nonce (delete.rs:237-281) does not — structure validation for delete is only run inside build_signed_document_delete_transition. Native callers using the builder's sign_with_nonce directly therefore get different validation behavior depending on which kind of transition they build, which is surprising. Either move the validation into sign_with_nonce (consistent with create/replace) or document the asymmetry explicitly in the delete builder's doc comment.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [BLOCKING] lines 227-244: DocumentCreateTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings
`DocumentCreateTransitionBuilder::with_settings` (create.rs:107-110) stores the incoming `PutSettings` whole into `self.settings`, but `sign_with_nonce` (create.rs:227-244) builds the transition off of `self.user_fee_increase.unwrap_or_default()` and `self.state_transition_creation_options` only — it never falls back to the values in `self.settings`. This PR teaches `Sdk::document_create` to fix that by destructuring the builder, overlaying `user_fee_increase` / `state_transition_creation_options` onto `effective_settings`, and passing it to `build_signed_document_create_transition` (create.rs:286-332). The result is an asymmetric foot-gun: a native two-phase caller who does `builder.with_settings(PutSettings { user_fee_increase: Some(42), state_transition_creation_options: Some(opts), .. }).sign(sdk, ...)` silently signs a transition with `user_fee_increase = 0` and no creation options, while the same builder handed to `sdk.document_create(builder, ...)` gets the requested 42 and opts. Because `user_fee_increase` is part of the signed transition, this is a correctness gap, not cosmetics. The same fix applied at the one-shot call site should live inside the builder (or `with_settings` should extract the two fields up-front the way `DocumentDeleteTransitionBuilder::with_settings` (delete.rs:146-160) already does).
In `packages/rs-sdk/src/platform/documents/transitions/replace.rs`:
- [BLOCKING] lines 191-221: DocumentReplaceTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings
Same root cause as the create builder. `with_settings` (replace.rs:101-104) stores settings whole, but `sign_with_nonce` (replace.rs:191-221) reads only `self.user_fee_increase` and `self.state_transition_creation_options`. Meanwhile this PR added a `Sdk::document_replace` overlay (replace.rs:284-312) that merges the builder fields into `effective_settings` before signing. So a caller using `.with_settings(PutSettings{ user_fee_increase: Some(N), .. }).sign(...)` produces a different signed wire transition than `.with_settings(...).then(sdk.document_replace(builder))`. Because the fee increase is bound into the signed transition this can silently understate the fee bump and drop creation options on the prepare-style API. Fix by extracting the two fields inside `with_settings` (mirroring delete.rs:146-160) or by overlaying from `self.settings` inside `sign_with_nonce`.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 202-225: DocumentDeleteTransitionBuilder::sign leaks the allocated nonce on signing failure
`DocumentCreateTransitionBuilder::sign` (create.rs:176-195) and `DocumentReplaceTransitionBuilder::sign` (replace.rs:159-178) both wrap the `sign_with_nonce` call in a `match` and call `sdk.rollback_identity_contract_nonce(...)` on the error branch. `DocumentDeleteTransitionBuilder::sign` (delete.rs:202-225) only uses `?` — a signing failure (e.g. signer error, document_type lookup failure inside `sign_with_nonce`) silently advances this SDK instance's nonce cache past a nonce the network never observes. The wasm-sdk `prepareDocumentDelete`/`document_delete` paths are protected because they route through `build_signed_document_delete_transition` which handles rollback, and the docstring at lines 184-189 acknowledges the gap and points users at `sign_with_nonce` for rollback control. But the builder shape matches create/replace exactly, so direct native callers reasonably expect symmetric behavior. Align `sign` with create/replace.
- [SUGGESTION] lines 237-281: DocumentDeleteTransitionBuilder::sign_with_nonce skips ensure_valid_state_transition_structure (asymmetric with create/replace)
`DocumentCreateTransitionBuilder::sign_with_nonce` (create.rs:241) and `DocumentReplaceTransitionBuilder::sign_with_nonce` (replace.rs:218) both end with `ensure_valid_state_transition_structure(&state_transition, platform_version)?` before returning. `DocumentDeleteTransitionBuilder::sign_with_nonce` (delete.rs:237-281) does not — structure validation for delete is only run inside `build_signed_document_delete_transition`. Native callers using the builder's `sign_with_nonce` directly therefore get different validation behavior depending on which kind of transition they build, which is surprising. Either move the validation into `sign_with_nonce` (consistent with create/replace) or document the asymmetry explicitly in the delete builder's doc comment.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1341-1376: WASM revision guards point users at sibling API that also rejects revision=0
`ensure_document_create_revision` collapses any non-`INITIAL_REVISION` value (including `Some(0)`) into "Use {replace_api_name} for existing documents", and `ensure_document_replace_revision` likewise tells callers of revision=0 to "Use {create_api_name} for new documents". After this PR, revision=0 is rejected on both paths (the rs-sdk side special-cases this via `ensure_revision_nonzero`), so the guidance is contradictory. JS callers who hit either error and follow the message will hit the same `InvalidArgument` on the other API. Add a dedicated arm for `Some(0)` that says revision 0 is invalid for both paths.
- [SUGGESTION] lines 660-720: prepareDocumentCreate performs network-backed contract fetch before rejecting invalid id/entropy
The new strict create-path invariant (`ensure_document_id_matches_entropy`) is supposed to make obviously invalid input fail fast. But `prepareDocumentCreate` awaits `self.get_or_fetch_contract(contract_id)` at line 696 before reaching `build_signed_document_create_transition`, where the id-vs-entropy check actually happens (after revision/id-match validation in `build_signed_document_create_transition_owned`). A caller with a malformed document still pays for network I/O and can see a transport error instead of `InvalidArgument`, despite the helper having all data needed to derive the expected id locally (`dataContractId`, `ownerId`, `documentTypeName`, `entropy` are all extracted before the fetch). The same ordering exists in `document_create()`. This weakens the new two-phase API for offline/retry-heavy clients.
- [SUGGESTION] lines 142-176: try_from_options_optional_token_payment_info decides ABI parsing path from attacker-controlled __type string
`get_class_type` (line 164) reads the `__type` getter set up by `impl_wasm_type_info!` to decide between two branches: if `__type == "TokenPaymentInfo"`, the helper invokes `TokenPaymentInfoWasm::try_from(&value)`, which routes through `IntoWasm` and reads the `__wbg_ptr` integer off the JS object before calling `ref_from_abi`. JS-side code (untrusted page or third-party package) can hand the WASM SDK a forged object like `{ __type: "TokenPaymentInfo", __wbg_ptr: 123456 }` and force Rust to reinterpret an arbitrary integer as a `TokenPaymentInfoWasm` pointer inside the wasm linear-memory heap. The wasm sandbox prevents host-memory escape, but the type confusion can still cause wasm traps or read structurally-valid garbage as the payment info bound into the signed transition — a defensive boundary worth tightening. Two separate concerns: (1) gate the wrapper branch on a stronger identity check than a JS-readable getter, and (2) the `else` branch currently calls `unchecked_into::<TokenPaymentInfoOptionsJs>` on any non-`"TokenPaymentInfo"` class type (e.g. a related wasm-dpp2 wrapper), which will produce a hard-to-diagnose error or partially-decoded payment info — gate the constructor branch on `class_type.is_empty()` (plain object) and reject other class types explicitly.
In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 24-40: map_validation_result silently drops UnsupportedFeatureError entries from mixed error lists — call out in PR notes
Previously `ensure_valid_state_transition_structure` returned `Ok` only when *every* error was `UnsupportedFeatureError`, otherwise it surfaced the full validation result. The new `map_validation_result` filters `UnsupportedFeatureError` entries out and returns only the remaining errors (validation.rs:29-39). The new behavior is sensible (no "not implemented" noise mixed with real errors) and the test at lines 100-111 covers it. But callers that depended on `Result.errors` faithfully reflecting what DPP returned — error counts, ordered iteration, diagnostic logging — will see different shapes. This is a public behavior change beyond document transitions; mention it in the PR description's compatibility note.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 511-532: Strict replace helper re-routes through the deprecated legacy dispatcher
`build_signed_document_replace_transition` validates `revision > INITIAL_REVISION` and then calls `build_signed_document_create_or_replace_transition_legacy(..., None, ...)`. That legacy dispatcher re-runs `ensure_revision_nonzero` and then dispatches into `build_and_sign_create_or_replace_after_nonce`, which carries the unreachable create branch (entropy resolution, doc id rewrite) for the sole purpose of choosing the replace path on revisions > INITIAL_REVISION. For symmetry with `build_signed_document_create_transition` / `build_signed_document_create_transition_owned`, add a dedicated `build_signed_document_replace_transition_inner` (validate → allocate nonce → `new_document_replacement_transition_from_document` → validate structure → rollback on err). As written, the strict replace path keeps an architectural dependency on the dispatcher it supersedes.
| @@ -167,6 +238,8 @@ impl DocumentCreateTransitionBuilder { | |||
| ) | |||
| .await?; | |||
|
|
|||
| ensure_valid_state_transition_structure(&state_transition, platform_version)?; | |||
|
|
|||
| Ok(state_transition) | |||
| } | |||
There was a problem hiding this comment.
🔴 Blocking: DocumentCreateTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings
DocumentCreateTransitionBuilder::with_settings (create.rs:107-110) stores the incoming PutSettings whole into self.settings, but sign_with_nonce (create.rs:227-244) builds the transition off of self.user_fee_increase.unwrap_or_default() and self.state_transition_creation_options only — it never falls back to the values in self.settings. This PR teaches Sdk::document_create to fix that by destructuring the builder, overlaying user_fee_increase / state_transition_creation_options onto effective_settings, and passing it to build_signed_document_create_transition (create.rs:286-332). The result is an asymmetric foot-gun: a native two-phase caller who does builder.with_settings(PutSettings { user_fee_increase: Some(42), state_transition_creation_options: Some(opts), .. }).sign(sdk, ...) silently signs a transition with user_fee_increase = 0 and no creation options, while the same builder handed to sdk.document_create(builder, ...) gets the requested 42 and opts. Because user_fee_increase is part of the signed transition, this is a correctness gap, not cosmetics. The same fix applied at the one-shot call site should live inside the builder (or with_settings should extract the two fields up-front the way DocumentDeleteTransitionBuilder::with_settings (delete.rs:146-160) already does).
source: ['codex', 'claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [BLOCKING] lines 227-244: DocumentCreateTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings
`DocumentCreateTransitionBuilder::with_settings` (create.rs:107-110) stores the incoming `PutSettings` whole into `self.settings`, but `sign_with_nonce` (create.rs:227-244) builds the transition off of `self.user_fee_increase.unwrap_or_default()` and `self.state_transition_creation_options` only — it never falls back to the values in `self.settings`. This PR teaches `Sdk::document_create` to fix that by destructuring the builder, overlaying `user_fee_increase` / `state_transition_creation_options` onto `effective_settings`, and passing it to `build_signed_document_create_transition` (create.rs:286-332). The result is an asymmetric foot-gun: a native two-phase caller who does `builder.with_settings(PutSettings { user_fee_increase: Some(42), state_transition_creation_options: Some(opts), .. }).sign(sdk, ...)` silently signs a transition with `user_fee_increase = 0` and no creation options, while the same builder handed to `sdk.document_create(builder, ...)` gets the requested 42 and opts. Because `user_fee_increase` is part of the signed transition, this is a correctness gap, not cosmetics. The same fix applied at the one-shot call site should live inside the builder (or `with_settings` should extract the two fields up-front the way `DocumentDeleteTransitionBuilder::with_settings` (delete.rs:146-160) already does).
| @@ -161,6 +215,8 @@ impl DocumentReplaceTransitionBuilder { | |||
| ) | |||
| .await?; | |||
|
|
|||
| ensure_valid_state_transition_structure(&state_transition, platform_version)?; | |||
|
|
|||
| Ok(state_transition) | |||
| } | |||
There was a problem hiding this comment.
🔴 Blocking: DocumentReplaceTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings
Same root cause as the create builder. with_settings (replace.rs:101-104) stores settings whole, but sign_with_nonce (replace.rs:191-221) reads only self.user_fee_increase and self.state_transition_creation_options. Meanwhile this PR added a Sdk::document_replace overlay (replace.rs:284-312) that merges the builder fields into effective_settings before signing. So a caller using .with_settings(PutSettings{ user_fee_increase: Some(N), .. }).sign(...) produces a different signed wire transition than .with_settings(...).then(sdk.document_replace(builder)). Because the fee increase is bound into the signed transition this can silently understate the fee bump and drop creation options on the prepare-style API. Fix by extracting the two fields inside with_settings (mirroring delete.rs:146-160) or by overlaying from self.settings inside sign_with_nonce.
source: ['codex', 'claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/replace.rs`:
- [BLOCKING] lines 191-221: DocumentReplaceTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings
Same root cause as the create builder. `with_settings` (replace.rs:101-104) stores settings whole, but `sign_with_nonce` (replace.rs:191-221) reads only `self.user_fee_increase` and `self.state_transition_creation_options`. Meanwhile this PR added a `Sdk::document_replace` overlay (replace.rs:284-312) that merges the builder fields into `effective_settings` before signing. So a caller using `.with_settings(PutSettings{ user_fee_increase: Some(N), .. }).sign(...)` produces a different signed wire transition than `.with_settings(...).then(sdk.document_replace(builder))`. Because the fee increase is bound into the signed transition this can silently understate the fee bump and drop creation options on the prepare-style API. Fix by extracting the two fields inside `with_settings` (mirroring delete.rs:146-160) or by overlaying from `self.settings` inside `sign_with_nonce`.
| ) | ||
| .await?; | ||
|
|
||
| self.sign_with_nonce( | ||
| identity_contract_nonce, | ||
| identity_public_key, | ||
| signer, | ||
| platform_version, | ||
| ) | ||
| .await | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: DocumentDeleteTransitionBuilder::sign leaks the allocated nonce on signing failure
DocumentCreateTransitionBuilder::sign (create.rs:176-195) and DocumentReplaceTransitionBuilder::sign (replace.rs:159-178) both wrap the sign_with_nonce call in a match and call sdk.rollback_identity_contract_nonce(...) on the error branch. DocumentDeleteTransitionBuilder::sign (delete.rs:202-225) only uses ? — a signing failure (e.g. signer error, document_type lookup failure inside sign_with_nonce) silently advances this SDK instance's nonce cache past a nonce the network never observes. The wasm-sdk prepareDocumentDelete/document_delete paths are protected because they route through build_signed_document_delete_transition which handles rollback, and the docstring at lines 184-189 acknowledges the gap and points users at sign_with_nonce for rollback control. But the builder shape matches create/replace exactly, so direct native callers reasonably expect symmetric behavior. Align sign with create/replace.
💡 Suggested change
| ) | |
| .await?; | |
| self.sign_with_nonce( | |
| identity_contract_nonce, | |
| identity_public_key, | |
| signer, | |
| platform_version, | |
| ) | |
| .await | |
| } | |
| pub async fn sign( | |
| &self, | |
| sdk: &Sdk, | |
| identity_public_key: &IdentityPublicKey, | |
| signer: &impl Signer<IdentityPublicKey>, | |
| platform_version: &PlatformVersion, | |
| ) -> Result<StateTransition, Error> { | |
| let identity_contract_nonce = sdk | |
| .get_identity_contract_nonce( | |
| self.owner_id, | |
| self.data_contract.id(), | |
| true, | |
| self.settings, | |
| ) | |
| .await?; | |
| match self | |
| .sign_with_nonce( | |
| identity_contract_nonce, | |
| identity_public_key, | |
| signer, | |
| platform_version, | |
| ) | |
| .await | |
| { | |
| Ok(transition) => Ok(transition), | |
| Err(err) => { | |
| sdk.rollback_identity_contract_nonce( | |
| self.owner_id, | |
| self.data_contract.id(), | |
| identity_contract_nonce, | |
| ) | |
| .await; | |
| Err(err) | |
| } | |
| } | |
| } |
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 202-225: DocumentDeleteTransitionBuilder::sign leaks the allocated nonce on signing failure
`DocumentCreateTransitionBuilder::sign` (create.rs:176-195) and `DocumentReplaceTransitionBuilder::sign` (replace.rs:159-178) both wrap the `sign_with_nonce` call in a `match` and call `sdk.rollback_identity_contract_nonce(...)` on the error branch. `DocumentDeleteTransitionBuilder::sign` (delete.rs:202-225) only uses `?` — a signing failure (e.g. signer error, document_type lookup failure inside `sign_with_nonce`) silently advances this SDK instance's nonce cache past a nonce the network never observes. The wasm-sdk `prepareDocumentDelete`/`document_delete` paths are protected because they route through `build_signed_document_delete_transition` which handles rollback, and the docstring at lines 184-189 acknowledges the gap and points users at `sign_with_nonce` for rollback control. But the builder shape matches create/replace exactly, so direct native callers reasonably expect symmetric behavior. Align `sign` with create/replace.
| fn ensure_document_create_revision( | ||
| revision: Option<u64>, | ||
| replace_api_name: &str, | ||
| ) -> Result<(), WasmSdkError> { | ||
| if let Some(revision) = revision { | ||
| if revision != INITIAL_REVISION { | ||
| return Err(WasmSdkError::invalid_argument(format!( | ||
| "Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.", | ||
| revision, INITIAL_REVISION, replace_api_name, | ||
| ))); | ||
| } | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| fn ensure_document_replace_revision( | ||
| revision: Option<u64>, | ||
| create_api_name: &str, | ||
| ) -> Result<(), WasmSdkError> { | ||
| let revision = revision.ok_or_else(|| { | ||
| WasmSdkError::invalid_argument(format!( | ||
| "Document must have a revision set for replace. Use {} for new documents.", | ||
| create_api_name, | ||
| )) | ||
| })?; | ||
|
|
||
| if revision <= INITIAL_REVISION { | ||
| return Err(WasmSdkError::invalid_argument(format!( | ||
| "Document revision is {} but replace requires revision > {}. Use {} for new documents.", | ||
| revision, INITIAL_REVISION, create_api_name, | ||
| ))); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: WASM revision guards point users at sibling API that also rejects revision=0
ensure_document_create_revision collapses any non-INITIAL_REVISION value (including Some(0)) into "Use {replace_api_name} for existing documents", and ensure_document_replace_revision likewise tells callers of revision=0 to "Use {create_api_name} for new documents". After this PR, revision=0 is rejected on both paths (the rs-sdk side special-cases this via ensure_revision_nonzero), so the guidance is contradictory. JS callers who hit either error and follow the message will hit the same InvalidArgument on the other API. Add a dedicated arm for Some(0) that says revision 0 is invalid for both paths.
💡 Suggested change
| fn ensure_document_create_revision( | |
| revision: Option<u64>, | |
| replace_api_name: &str, | |
| ) -> Result<(), WasmSdkError> { | |
| if let Some(revision) = revision { | |
| if revision != INITIAL_REVISION { | |
| return Err(WasmSdkError::invalid_argument(format!( | |
| "Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.", | |
| revision, INITIAL_REVISION, replace_api_name, | |
| ))); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn ensure_document_replace_revision( | |
| revision: Option<u64>, | |
| create_api_name: &str, | |
| ) -> Result<(), WasmSdkError> { | |
| let revision = revision.ok_or_else(|| { | |
| WasmSdkError::invalid_argument(format!( | |
| "Document must have a revision set for replace. Use {} for new documents.", | |
| create_api_name, | |
| )) | |
| })?; | |
| if revision <= INITIAL_REVISION { | |
| return Err(WasmSdkError::invalid_argument(format!( | |
| "Document revision is {} but replace requires revision > {}. Use {} for new documents.", | |
| revision, INITIAL_REVISION, create_api_name, | |
| ))); | |
| } | |
| Ok(()) | |
| } | |
| fn ensure_document_create_revision( | |
| revision: Option<u64>, | |
| replace_api_name: &str, | |
| ) -> Result<(), WasmSdkError> { | |
| match revision { | |
| None => Ok(()), | |
| Some(rev) if rev == INITIAL_REVISION => Ok(()), | |
| Some(0) => Err(WasmSdkError::invalid_argument(format!( | |
| "Document revision 0 is invalid. Use an unset revision or {} for create, or a revision greater than {} for {}.", | |
| INITIAL_REVISION, INITIAL_REVISION, replace_api_name, | |
| ))), | |
| Some(revision) => Err(WasmSdkError::invalid_argument(format!( | |
| "Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.", | |
| revision, INITIAL_REVISION, replace_api_name, | |
| ))), | |
| } | |
| } | |
| fn ensure_document_replace_revision( | |
| revision: Option<u64>, | |
| create_api_name: &str, | |
| ) -> Result<(), WasmSdkError> { | |
| match revision { | |
| Some(rev) if rev > INITIAL_REVISION => Ok(()), | |
| Some(0) => Err(WasmSdkError::invalid_argument(format!( | |
| "Document revision 0 is invalid. Use an unset revision or {} for {}, or a revision greater than {} for replace.", | |
| INITIAL_REVISION, create_api_name, INITIAL_REVISION, | |
| ))), | |
| Some(revision) => Err(WasmSdkError::invalid_argument(format!( | |
| "Document revision is {} but replace requires revision > {}. Use {} for new documents.", | |
| revision, INITIAL_REVISION, create_api_name, | |
| ))), | |
| None => Err(WasmSdkError::invalid_argument(format!( | |
| "Document must have a revision set for replace. Use {} for new documents.", | |
| create_api_name, | |
| ))), | |
| } | |
| } |
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1341-1376: WASM revision guards point users at sibling API that also rejects revision=0
`ensure_document_create_revision` collapses any non-`INITIAL_REVISION` value (including `Some(0)`) into "Use {replace_api_name} for existing documents", and `ensure_document_replace_revision` likewise tells callers of revision=0 to "Use {create_api_name} for new documents". After this PR, revision=0 is rejected on both paths (the rs-sdk side special-cases this via `ensure_revision_nonzero`), so the guidance is contradictory. JS callers who hit either error and follow the message will hit the same `InvalidArgument` on the other API. Add a dedicated arm for `Some(0)` that says revision 0 is invalid for both paths.
| pub async fn prepare_document_create( | ||
| &self, | ||
| options: PrepareDocumentCreateOptionsJs, | ||
| ) -> Result<StateTransitionWasm, WasmSdkError> { | ||
| // Extract document from options | ||
| let document_wasm = DocumentWasm::try_from_options(&options, "document")?; | ||
| let document: Document = document_wasm.clone().into(); | ||
|
|
||
| ensure_document_create_revision(document.revision(), "prepareDocumentReplace")?; | ||
|
|
||
| // Get metadata from document | ||
| let contract_id: Identifier = document_wasm.data_contract_id().into(); | ||
| let document_type_name = document_wasm.document_type_name(); | ||
|
|
||
| // Get entropy from document | ||
| let entropy = document_wasm.entropy().ok_or_else(|| { | ||
| WasmSdkError::invalid_argument("Document must have entropy set for creation") | ||
| })?; | ||
|
|
||
| if entropy.len() != 32 { | ||
| return Err(WasmSdkError::invalid_argument( | ||
| "Document entropy must be exactly 32 bytes", | ||
| )); | ||
| } | ||
|
|
||
| let mut entropy_array = [0u8; 32]; | ||
| entropy_array.copy_from_slice(&entropy); | ||
|
|
||
| // Extract identity key from options | ||
| let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; | ||
| let identity_key: IdentityPublicKey = identity_key_wasm.into(); | ||
|
|
||
| // Extract signer from options | ||
| let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; | ||
|
|
||
| // Fetch the data contract (using cache) | ||
| let data_contract = self.get_or_fetch_contract(contract_id).await?; | ||
|
|
||
| // Get document type (owned) | ||
| let document_type = get_document_type(&data_contract, &document_type_name)?; | ||
|
|
||
| // Extract settings from options | ||
| let settings = | ||
| try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into); | ||
| let token_payment_info = try_from_options_optional_token_payment_info(&options)?; | ||
|
|
||
| // Build, sign, and structurally validate the state transition without | ||
| // broadcasting it. Local pre-broadcast failures are rolled back inside | ||
| // rs-sdk so the identity-contract nonce cache cannot advance past a | ||
| // nonce the network never observed. | ||
| let state_transition = build_signed_document_create_transition( | ||
| self.inner_sdk(), | ||
| &document, | ||
| &document_type, | ||
| entropy_array, | ||
| &identity_key, | ||
| token_payment_info, | ||
| &signer, | ||
| settings, | ||
| ) | ||
| .await?; |
There was a problem hiding this comment.
🟡 Suggestion: prepareDocumentCreate performs network-backed contract fetch before rejecting invalid id/entropy
The new strict create-path invariant (ensure_document_id_matches_entropy) is supposed to make obviously invalid input fail fast. But prepareDocumentCreate awaits self.get_or_fetch_contract(contract_id) at line 696 before reaching build_signed_document_create_transition, where the id-vs-entropy check actually happens (after revision/id-match validation in build_signed_document_create_transition_owned). A caller with a malformed document still pays for network I/O and can see a transport error instead of InvalidArgument, despite the helper having all data needed to derive the expected id locally (dataContractId, ownerId, documentTypeName, entropy are all extracted before the fetch). The same ordering exists in document_create(). This weakens the new two-phase API for offline/retry-heavy clients.
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 660-720: prepareDocumentCreate performs network-backed contract fetch before rejecting invalid id/entropy
The new strict create-path invariant (`ensure_document_id_matches_entropy`) is supposed to make obviously invalid input fail fast. But `prepareDocumentCreate` awaits `self.get_or_fetch_contract(contract_id)` at line 696 before reaching `build_signed_document_create_transition`, where the id-vs-entropy check actually happens (after revision/id-match validation in `build_signed_document_create_transition_owned`). A caller with a malformed document still pays for network I/O and can see a transport error instead of `InvalidArgument`, despite the helper having all data needed to derive the expected id locally (`dataContractId`, `ownerId`, `documentTypeName`, `entropy` are all extracted before the fetch). The same ordering exists in `document_create()`. This weakens the new two-phase API for offline/retry-heavy clients.
| fn map_validation_result(result: SimpleConsensusValidationResult) -> Result<(), Error> { | ||
| if result.is_valid() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let real_errors: Vec<ConsensusError> = result | ||
| .errors | ||
| .into_iter() | ||
| .filter(|e| !is_unsupported_feature_error(e)) | ||
| .collect(); | ||
|
|
||
| if real_errors.is_empty() { | ||
| Ok(()) | ||
| } else { | ||
| Err(SimpleConsensusValidationResult::new_with_errors(real_errors).into()) | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: map_validation_result silently drops UnsupportedFeatureError entries from mixed error lists — call out in PR notes
Previously ensure_valid_state_transition_structure returned Ok only when every error was UnsupportedFeatureError, otherwise it surfaced the full validation result. The new map_validation_result filters UnsupportedFeatureError entries out and returns only the remaining errors (validation.rs:29-39). The new behavior is sensible (no "not implemented" noise mixed with real errors) and the test at lines 100-111 covers it. But callers that depended on Result.errors faithfully reflecting what DPP returned — error counts, ordered iteration, diagnostic logging — will see different shapes. This is a public behavior change beyond document transitions; mention it in the PR description's compatibility note.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 24-40: map_validation_result silently drops UnsupportedFeatureError entries from mixed error lists — call out in PR notes
Previously `ensure_valid_state_transition_structure` returned `Ok` only when *every* error was `UnsupportedFeatureError`, otherwise it surfaced the full validation result. The new `map_validation_result` filters `UnsupportedFeatureError` entries out and returns only the remaining errors (validation.rs:29-39). The new behavior is sensible (no "not implemented" noise mixed with real errors) and the test at lines 100-111 covers it. But callers that depended on `Result.errors` faithfully reflecting what DPP returned — error counts, ordered iteration, diagnostic logging — will see different shapes. This is a public behavior change beyond document transitions; mention it in the PR description's compatibility note.
| return Ok(None); | ||
| } | ||
|
|
||
| let token_payment_info = TokenPaymentInfoWasm::constructor( | ||
| token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(), | ||
| ) | ||
| .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?; | ||
| // Accept either an existing wasm-dpp2 `TokenPaymentInfo` class instance | ||
| // (e.g. produced by `new TokenPaymentInfo(...)` or returned by another | ||
| // wasm-sdk accessor) **or** a plain `DocumentTokenPaymentInfo` options | ||
| // bag. We detect the class type via the `__type` getter set up by | ||
| // `impl_wasm_type_info!` and convert via `TryFrom<&JsValue>` (which uses | ||
| // the `IntoWasm` pointer extraction) before falling back to the | ||
| // constructor-from-options path for plain objects. | ||
| let class_type = get_class_type(&token_payment_info_value).unwrap_or_default(); | ||
| let token_payment_info = if class_type == "TokenPaymentInfo" { | ||
| TokenPaymentInfoWasm::try_from(&token_payment_info_value) | ||
| .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))? | ||
| } else { | ||
| TokenPaymentInfoWasm::constructor( | ||
| token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(), | ||
| ) | ||
| .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))? | ||
| }; | ||
|
|
||
| Ok(Some(token_payment_info.into())) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: try_from_options_optional_token_payment_info decides ABI parsing path from attacker-controlled __type string
get_class_type (line 164) reads the __type getter set up by impl_wasm_type_info! to decide between two branches: if __type == "TokenPaymentInfo", the helper invokes TokenPaymentInfoWasm::try_from(&value), which routes through IntoWasm and reads the __wbg_ptr integer off the JS object before calling ref_from_abi. JS-side code (untrusted page or third-party package) can hand the WASM SDK a forged object like { __type: "TokenPaymentInfo", __wbg_ptr: 123456 } and force Rust to reinterpret an arbitrary integer as a TokenPaymentInfoWasm pointer inside the wasm linear-memory heap. The wasm sandbox prevents host-memory escape, but the type confusion can still cause wasm traps or read structurally-valid garbage as the payment info bound into the signed transition — a defensive boundary worth tightening. Two separate concerns: (1) gate the wrapper branch on a stronger identity check than a JS-readable getter, and (2) the else branch currently calls unchecked_into::<TokenPaymentInfoOptionsJs> on any non-"TokenPaymentInfo" class type (e.g. a related wasm-dpp2 wrapper), which will produce a hard-to-diagnose error or partially-decoded payment info — gate the constructor branch on class_type.is_empty() (plain object) and reject other class types explicitly.
source: ['codex', 'claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 142-176: try_from_options_optional_token_payment_info decides ABI parsing path from attacker-controlled __type string
`get_class_type` (line 164) reads the `__type` getter set up by `impl_wasm_type_info!` to decide between two branches: if `__type == "TokenPaymentInfo"`, the helper invokes `TokenPaymentInfoWasm::try_from(&value)`, which routes through `IntoWasm` and reads the `__wbg_ptr` integer off the JS object before calling `ref_from_abi`. JS-side code (untrusted page or third-party package) can hand the WASM SDK a forged object like `{ __type: "TokenPaymentInfo", __wbg_ptr: 123456 }` and force Rust to reinterpret an arbitrary integer as a `TokenPaymentInfoWasm` pointer inside the wasm linear-memory heap. The wasm sandbox prevents host-memory escape, but the type confusion can still cause wasm traps or read structurally-valid garbage as the payment info bound into the signed transition — a defensive boundary worth tightening. Two separate concerns: (1) gate the wrapper branch on a stronger identity check than a JS-readable getter, and (2) the `else` branch currently calls `unchecked_into::<TokenPaymentInfoOptionsJs>` on any non-`"TokenPaymentInfo"` class type (e.g. a related wasm-dpp2 wrapper), which will produce a hard-to-diagnose error or partially-decoded payment info — gate the constructor branch on `class_type.is_empty()` (plain object) and reject other class types explicitly.
| pub async fn build_signed_document_replace_transition<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| ensure_revision_for_replace(document.revision())?; | ||
| build_signed_document_create_or_replace_transition_legacy( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| None, // entropy is unused on the replace path | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| ) | ||
| .await | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Strict replace helper re-routes through the deprecated legacy dispatcher
build_signed_document_replace_transition validates revision > INITIAL_REVISION and then calls build_signed_document_create_or_replace_transition_legacy(..., None, ...). That legacy dispatcher re-runs ensure_revision_nonzero and then dispatches into build_and_sign_create_or_replace_after_nonce, which carries the unreachable create branch (entropy resolution, doc id rewrite) for the sole purpose of choosing the replace path on revisions > INITIAL_REVISION. For symmetry with build_signed_document_create_transition / build_signed_document_create_transition_owned, add a dedicated build_signed_document_replace_transition_inner (validate → allocate nonce → new_document_replacement_transition_from_document → validate structure → rollback on err). As written, the strict replace path keeps an architectural dependency on the dispatcher it supersedes.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 511-532: Strict replace helper re-routes through the deprecated legacy dispatcher
`build_signed_document_replace_transition` validates `revision > INITIAL_REVISION` and then calls `build_signed_document_create_or_replace_transition_legacy(..., None, ...)`. That legacy dispatcher re-runs `ensure_revision_nonzero` and then dispatches into `build_and_sign_create_or_replace_after_nonce`, which carries the unreachable create branch (entropy resolution, doc id rewrite) for the sole purpose of choosing the replace path on revisions > INITIAL_REVISION. For symmetry with `build_signed_document_create_transition` / `build_signed_document_create_transition_owned`, add a dedicated `build_signed_document_replace_transition_inner` (validate → allocate nonce → `new_document_replacement_transition_from_document` → validate structure → rollback on err). As written, the strict replace path keeps an architectural dependency on the dispatcher it supersedes.
| #[deprecated( | ||
| note = "use build_signed_document_create_transition or build_signed_document_replace_transition for strict intent validation" | ||
| )] | ||
| pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| document_state_transition_entropy: Option<[u8; 32]>, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| build_signed_document_create_or_replace_transition_legacy( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| ) | ||
| .await | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Newly introduced public helper build_signed_document_create_or_replace_transition is #[deprecated] from day one
build_signed_document_create_or_replace_transition is introduced as pub in this PR with #[deprecated] already applied, and the docstring says it is "retained as a public, source-compatible entry point for native callers that already depend on it." There are no such callers — the function is new, and all in-tree call sites use the strict helpers or call the private *_legacy impl directly. Exposing a deprecated public function from day one expands API surface for no concrete consumer. Keep build_signed_document_create_or_replace_transition_legacy private and re-add a public wrapper only when an external caller actually needs it.
source: ['claude']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR #3091 adds well-structured prepare_* document state transition APIs with careful nonce rollback semantics and thorough test coverage. The reviewed worktree shows no consensus-critical changes and no security regressions introduced by this PR. The most actionable issue is a stale assertion in two wasm unit tests that will fail because the new Rust revision-0 message intentionally omits the sibling-API name. Other findings are suggestions or doc nits. Two reviewer findings were dropped: codex-security's protocol-version-poisoning claim targets pre-existing code in sdk.rs that this PR does not modify, and a low-value performance nit.
Reviewed commit: 730f6b3
🟡 6 suggestion(s) | 💬 4 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [SUGGESTION] lines 138-188: Revision-0 wasm tests still assert the old sibling-API wording and will fail
Both revision-0 tests still expect the error message to mention the sibling API: line 152 matches `/prepareDocumentReplace/` and line 186 matches `/prepareDocumentCreate/`. This PR changed the Rust guards (`ensure_document_create_revision` / `ensure_document_replace_revision` at packages/wasm-sdk/src/state_transitions/document.rs:1459-1463 and 1481-1484) to emit a dedicated message for revision 0 that intentionally does NOT name the sibling API — the comment in the Rust source explicitly says "do not point users at the sibling API." These two assertions will fail as soon as the unit suite is run. Update the matchers to the new shared message (`revision 0 is invalid for both create and replace`) and assert that the sibling-API name is absent.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 955-993: Prepare/document delete entry points dereference attacker-controlled `__wbg_ptr` while sibling tokenPaymentInfo path is hardened
`prepare_document_delete` (and the existing `document_delete`) branches on `get_class_type(&document_js) == Some("Document")` and then calls `document_js.to_wasm::<DocumentWasm>("Document")`. That path resolves to `generic_of_js_val` in packages/wasm-dpp2/src/utils.rs:114-141, which reads the JS-controlled `__wbg_ptr` property and invokes `unsafe { T::ref_from_abi(ptr_u32) }` after checking only the also-JS-controlled `__type` string. A forged JS object `{ __type: "Document", __wbg_ptr: <attacker u32> }` therefore materializes a `&DocumentWasm` from arbitrary wasm linear-memory bytes — undefined behavior per wasm-bindgen's `RefFromWasmAbi` contract.
This is the exact attack class the PR explicitly defends against for `tokenPaymentInfo` a few hundred lines earlier (packages/wasm-sdk/src/state_transitions/document.rs:142-194), with the security comment stating: "A forged object can spoof the public `__type` getter/string, but it cannot force us to dereference an arbitrary wasm pointer when we only copy public fields into a fresh options bag." The same threat model applies to `document` on every prepare entry point this PR adds (`prepare_document_create` / `prepare_document_replace` reach the same code via `DocumentWasm::try_from_options`). The pattern is consistent with pre-existing `document_create` / `document_replace` / `document_delete`, so this is not a regression, but the PR expands the attack surface with three new entry points while leaving the safer pattern unused for `Document`. Apply the same Reflect-and-copy-public-fields defense to the `document` branch, or accept only structural objects (`{ id, ownerId, dataContractId, documentTypeName, revision? }`) on the prepare APIs.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 117-167: Strict create-path id check hard-pins to v0 derivation despite project-wide PlatformVersion dispatch
`derive_document_id_v0` and `ensure_document_id_matches_entropy` are hard-pinned to `Document::generate_document_id_v0`. The helper's doc comment acknowledges this and instructs future readers to grep both names if the derivation ever changes. The risk is that this helper is the new public pre-broadcast gate for `build_signed_document_create_transition` (and thus `prepareDocumentCreate`), so if a future protocol version introduces a new id derivation, every caller on the strict path will start rejecting otherwise-valid creates locally as `InvalidArgument` rather than letting the consensus rules decide. Given the rest of the platform consistently dispatches on `PlatformVersion`, the more defensive shape is to branch internally on the SDK's current protocol version here instead of relying on a future grep. Not a defect today, but worth either adding the version dispatch now or adding a TODO that ties the migration to a specific consensus-version bump.
In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 123-137: `with_settings` body duplicated verbatim across create/replace/delete builders
The new `with_settings` implementation is identical line-for-line in create.rs, replace.rs, and delete.rs (extract `user_fee_increase` and `state_transition_creation_options` into dedicated fields only when `None`, clear those two `PutSettings` fields, store the remainder). The behavioral contract is non-trivial (explicit setter wins, single source of truth, preserve other fields) and is currently maintained by copying the same code into three files plus three nearly-identical test suites. The failure mode this invites is divergence — someone adding a new `PutSettings` field that needs the same treatment, or fixing a bug in one but not the others. Extract this into a single helper (e.g. an inherent `fn split_dedicated_fields(self) -> (Option<UserFeeIncrease>, Option<StateTransitionCreationOptions>, PutSettings)` on `PutSettings`) so the three builder methods reduce to one or two lines that call it. The test tables can likewise be parameterized.
In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 17-40: `UnsupportedFeatureError` filter assumes the variant is only a "not implemented" placeholder
`map_validation_result` filters every `UnsupportedFeatureError` out of mixed structure-validation results and treats an all-unsupported result as `Ok`. That is correct for identity-transition arms in `rs-dpp/src/state_transition/mod.rs:1418-1426`, but DPP also uses `UnsupportedFeatureError` as a legitimate consensus rejection — e.g. `TokenConfigUpdateTransition::validate_structure_v0` rejects perpetual-distribution changes with `UnsupportedFeatureError::new("of changing perpetual distribution", ...)`. Now that this helper is `pub` and called from many transition paths (transfer, vote, withdrawal, put_contract, purchase_document, etc.), a future `BatchTransition` that mixes a document op with an unsupported token-config update would have its real rejection silently filtered and only surface server-side after broadcast. Within this PR's scope the prepare flows only build single-document batches, so the concern does not materialize today — but the filter should either be tightened to acknowledge the second meaning or narrowed to the specific transition kinds where it's known safe (identity-based STs).
In `packages/rs-sdk/README.md`:
- [SUGGESTION] lines 49-55: rs-sdk compatibility note omits the new id-vs-entropy invariant for native callers
The compatibility section calls out the new `with_settings` pass-through behavior and the delete-builder rollback + structure-validation additions, but does not mention the new id-vs-entropy invariant enforced inside `DocumentCreateTransitionBuilder::sign_with_nonce` (create.rs:243-279) and `PutDocument::put_to_platform` (put_document.rs:704-733). Native rs-sdk callers that previously built a `DocumentCreateTransitionBuilder`, or called `put_to_platform` with `entropy = Some(..)`, with a hand-picked `document.id` that did not match `Document::generate_document_id_v0(...)` will now see `Error::InvalidArgument` instead of a silently signed transition. The README is the durable reference for external consumers and should call this out alongside the other create/replace/delete behavior changes.
| it('rejects a document with revision 0 (would silently be a replace)', async () => { | ||
| // `build_document_create_or_replace_transition` routes any `revision != INITIAL_REVISION` | ||
| // to the replace branch, so `revision = 0` must be rejected too — otherwise it would | ||
| // silently produce a replace transition instead of a create. | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument({ revision: 0 }); | ||
|
|
||
| try { | ||
| await client.prepareDocumentCreate({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentCreate to reject revision 0'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision/i); | ||
| expect(e.message).to.match(/prepareDocumentReplace/); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe('prepareDocumentReplace()', () => { | ||
| it('rejects a document with no revision', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument(); | ||
| // The Document constructor defaults revision to 1; clear it to exercise | ||
| // the "must have a revision set" guard in prepare_document_replace. | ||
| document.revision = null; | ||
|
|
||
| try { | ||
| await client.prepareDocumentReplace({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentReplace to reject missing revision'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision/i); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a document with revision 0', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument({ revision: 0 }); | ||
|
|
||
| try { | ||
| await client.prepareDocumentReplace({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentReplace to reject revision 0'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision/i); | ||
| expect(e.message).to.match(/prepareDocumentCreate/); | ||
| } | ||
| }); |
There was a problem hiding this comment.
🟡 Suggestion: Revision-0 wasm tests still assert the old sibling-API wording and will fail
Both revision-0 tests still expect the error message to mention the sibling API: line 152 matches /prepareDocumentReplace/ and line 186 matches /prepareDocumentCreate/. This PR changed the Rust guards (ensure_document_create_revision / ensure_document_replace_revision at packages/wasm-sdk/src/state_transitions/document.rs:1459-1463 and 1481-1484) to emit a dedicated message for revision 0 that intentionally does NOT name the sibling API — the comment in the Rust source explicitly says "do not point users at the sibling API." These two assertions will fail as soon as the unit suite is run. Update the matchers to the new shared message (revision 0 is invalid for both create and replace) and assert that the sibling-API name is absent.
💡 Suggested change
| it('rejects a document with revision 0 (would silently be a replace)', async () => { | |
| // `build_document_create_or_replace_transition` routes any `revision != INITIAL_REVISION` | |
| // to the replace branch, so `revision = 0` must be rejected too — otherwise it would | |
| // silently produce a replace transition instead of a create. | |
| const { signer, identityKey } = buildSigner(); | |
| const document = buildDocument({ revision: 0 }); | |
| try { | |
| await client.prepareDocumentCreate({ document, identityKey, signer }); | |
| expect.fail('expected prepareDocumentCreate to reject revision 0'); | |
| } catch (e) { | |
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | |
| expect(e.name).to.equal('InvalidArgument'); | |
| expect(e.message).to.match(/revision/i); | |
| expect(e.message).to.match(/prepareDocumentReplace/); | |
| } | |
| }); | |
| }); | |
| describe('prepareDocumentReplace()', () => { | |
| it('rejects a document with no revision', async () => { | |
| const { signer, identityKey } = buildSigner(); | |
| const document = buildDocument(); | |
| // The Document constructor defaults revision to 1; clear it to exercise | |
| // the "must have a revision set" guard in prepare_document_replace. | |
| document.revision = null; | |
| try { | |
| await client.prepareDocumentReplace({ document, identityKey, signer }); | |
| expect.fail('expected prepareDocumentReplace to reject missing revision'); | |
| } catch (e) { | |
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | |
| expect(e.name).to.equal('InvalidArgument'); | |
| expect(e.message).to.match(/revision/i); | |
| } | |
| }); | |
| it('rejects a document with revision 0', async () => { | |
| const { signer, identityKey } = buildSigner(); | |
| const document = buildDocument({ revision: 0 }); | |
| try { | |
| await client.prepareDocumentReplace({ document, identityKey, signer }); | |
| expect.fail('expected prepareDocumentReplace to reject revision 0'); | |
| } catch (e) { | |
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | |
| expect(e.name).to.equal('InvalidArgument'); | |
| expect(e.message).to.match(/revision/i); | |
| expect(e.message).to.match(/prepareDocumentCreate/); | |
| } | |
| }); | |
| it('rejects a document with revision 0 (would silently be a replace)', async () => { | |
| const { signer, identityKey } = buildSigner(); | |
| const document = buildDocument({ revision: 0 }); | |
| try { | |
| await client.prepareDocumentCreate({ document, identityKey, signer }); | |
| expect.fail('expected prepareDocumentCreate to reject revision 0'); | |
| } catch (e) { | |
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | |
| expect(e.name).to.equal('InvalidArgument'); | |
| expect(e.message).to.match(/revision 0 is invalid for both create and replace/i); | |
| expect(e.message).to.not.match(/prepareDocumentReplace/); | |
| } | |
| }); | |
| }); | |
| describe('prepareDocumentReplace()', () => { | |
| it('rejects a document with no revision', async () => { | |
| const { signer, identityKey } = buildSigner(); | |
| const document = buildDocument(); | |
| document.revision = null; | |
| try { | |
| await client.prepareDocumentReplace({ document, identityKey, signer }); | |
| expect.fail('expected prepareDocumentReplace to reject missing revision'); | |
| } catch (e) { | |
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | |
| expect(e.name).to.equal('InvalidArgument'); | |
| expect(e.message).to.match(/revision/i); | |
| } | |
| }); | |
| it('rejects a document with revision 0', async () => { | |
| const { signer, identityKey } = buildSigner(); | |
| const document = buildDocument({ revision: 0 }); | |
| try { | |
| await client.prepareDocumentReplace({ document, identityKey, signer }); | |
| expect.fail('expected prepareDocumentReplace to reject revision 0'); | |
| } catch (e) { | |
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | |
| expect(e.name).to.equal('InvalidArgument'); | |
| expect(e.message).to.match(/revision 0 is invalid for both create and replace/i); | |
| expect(e.message).to.not.match(/prepareDocumentCreate/); | |
| } |
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [SUGGESTION] lines 138-188: Revision-0 wasm tests still assert the old sibling-API wording and will fail
Both revision-0 tests still expect the error message to mention the sibling API: line 152 matches `/prepareDocumentReplace/` and line 186 matches `/prepareDocumentCreate/`. This PR changed the Rust guards (`ensure_document_create_revision` / `ensure_document_replace_revision` at packages/wasm-sdk/src/state_transitions/document.rs:1459-1463 and 1481-1484) to emit a dedicated message for revision 0 that intentionally does NOT name the sibling API — the comment in the Rust source explicitly says "do not point users at the sibling API." These two assertions will fail as soon as the unit suite is run. Update the matchers to the new shared message (`revision 0 is invalid for both create and replace`) and assert that the sibling-API name is absent.
| pub async fn prepare_document_delete( | ||
| &self, | ||
| options: PrepareDocumentDeleteOptionsJs, | ||
| ) -> Result<StateTransitionWasm, WasmSdkError> { | ||
| // Extract document field - can be either a Document instance or plain object | ||
| let document_js = js_sys::Reflect::get(&options, &JsValue::from_str("document")) | ||
| .map_err(|_| WasmSdkError::invalid_argument("document is required"))?; | ||
|
|
||
| if document_js.is_undefined() || document_js.is_null() { | ||
| return Err(WasmSdkError::invalid_argument("document is required")); | ||
| } | ||
|
|
||
| // Check if it's a Document instance or a plain object with fields | ||
| let (document_id, owner_id, contract_id, document_type_name): ( | ||
| Identifier, | ||
| Identifier, | ||
| Identifier, | ||
| String, | ||
| ) = if get_class_type(&document_js).ok().as_deref() == Some("Document") { | ||
| let doc: DocumentWasm = document_js | ||
| .to_wasm::<DocumentWasm>("Document") | ||
| .map(|boxed| (*boxed).clone())?; | ||
| let doc_inner: Document = doc.clone().into(); | ||
| ( | ||
| doc.id().into(), | ||
| doc_inner.owner_id(), | ||
| doc.data_contract_id().into(), | ||
| doc.document_type_name(), | ||
| ) | ||
| } else { | ||
| ( | ||
| IdentifierWasm::try_from_options(&document_js, "id")?.into(), | ||
| IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(), | ||
| IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(), | ||
| try_from_options_with(&document_js, "documentTypeName", |v| { | ||
| try_to_string(v, "documentTypeName") | ||
| })?, | ||
| ) | ||
| }; |
There was a problem hiding this comment.
🟡 Suggestion: Prepare/document delete entry points dereference attacker-controlled __wbg_ptr while sibling tokenPaymentInfo path is hardened
prepare_document_delete (and the existing document_delete) branches on get_class_type(&document_js) == Some("Document") and then calls document_js.to_wasm::<DocumentWasm>("Document"). That path resolves to generic_of_js_val in packages/wasm-dpp2/src/utils.rs:114-141, which reads the JS-controlled __wbg_ptr property and invokes unsafe { T::ref_from_abi(ptr_u32) } after checking only the also-JS-controlled __type string. A forged JS object { __type: "Document", __wbg_ptr: <attacker u32> } therefore materializes a &DocumentWasm from arbitrary wasm linear-memory bytes — undefined behavior per wasm-bindgen's RefFromWasmAbi contract.
This is the exact attack class the PR explicitly defends against for tokenPaymentInfo a few hundred lines earlier (packages/wasm-sdk/src/state_transitions/document.rs:142-194), with the security comment stating: "A forged object can spoof the public __type getter/string, but it cannot force us to dereference an arbitrary wasm pointer when we only copy public fields into a fresh options bag." The same threat model applies to document on every prepare entry point this PR adds (prepare_document_create / prepare_document_replace reach the same code via DocumentWasm::try_from_options). The pattern is consistent with pre-existing document_create / document_replace / document_delete, so this is not a regression, but the PR expands the attack surface with three new entry points while leaving the safer pattern unused for Document. Apply the same Reflect-and-copy-public-fields defense to the document branch, or accept only structural objects ({ id, ownerId, dataContractId, documentTypeName, revision? }) on the prepare APIs.
source: ['claude', 'codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 955-993: Prepare/document delete entry points dereference attacker-controlled `__wbg_ptr` while sibling tokenPaymentInfo path is hardened
`prepare_document_delete` (and the existing `document_delete`) branches on `get_class_type(&document_js) == Some("Document")` and then calls `document_js.to_wasm::<DocumentWasm>("Document")`. That path resolves to `generic_of_js_val` in packages/wasm-dpp2/src/utils.rs:114-141, which reads the JS-controlled `__wbg_ptr` property and invokes `unsafe { T::ref_from_abi(ptr_u32) }` after checking only the also-JS-controlled `__type` string. A forged JS object `{ __type: "Document", __wbg_ptr: <attacker u32> }` therefore materializes a `&DocumentWasm` from arbitrary wasm linear-memory bytes — undefined behavior per wasm-bindgen's `RefFromWasmAbi` contract.
This is the exact attack class the PR explicitly defends against for `tokenPaymentInfo` a few hundred lines earlier (packages/wasm-sdk/src/state_transitions/document.rs:142-194), with the security comment stating: "A forged object can spoof the public `__type` getter/string, but it cannot force us to dereference an arbitrary wasm pointer when we only copy public fields into a fresh options bag." The same threat model applies to `document` on every prepare entry point this PR adds (`prepare_document_create` / `prepare_document_replace` reach the same code via `DocumentWasm::try_from_options`). The pattern is consistent with pre-existing `document_create` / `document_replace` / `document_delete`, so this is not a regression, but the PR expands the attack surface with three new entry points while leaving the safer pattern unused for `Document`. Apply the same Reflect-and-copy-public-fields defense to the `document` branch, or accept only structural objects (`{ id, ownerId, dataContractId, documentTypeName, revision? }`) on the prepare APIs.
| /// # v0 versioning note | ||
| /// | ||
| /// This helper is intentionally hard-pinned to | ||
| /// [`Document::generate_document_id_v0`]. A future protocol version that | ||
| /// introduces a new document-id derivation should change this helper (or | ||
| /// branch internally on platform version) — grep for `derive_document_id_v0` | ||
| /// **and** `generate_document_id_v0` to catch every call site that needs to | ||
| /// migrate together. | ||
| fn derive_document_id_v0( | ||
| document_type: DocumentTypeRef<'_>, | ||
| owner_id: &Identifier, | ||
| entropy: &[u8; 32], | ||
| ) -> Identifier { | ||
| Document::generate_document_id_v0( | ||
| &document_type.data_contract_id(), | ||
| owner_id, | ||
| document_type.name(), | ||
| entropy.as_slice(), | ||
| ) | ||
| } | ||
|
|
||
| /// Strict create-path id check: documents handed to | ||
| /// [`build_signed_document_create_transition`] must already have their `id` | ||
| /// derived from the supplied entropy via [`derive_document_id_v0`] (currently | ||
| /// [`Document::generate_document_id_v0`]). | ||
| /// | ||
| /// This guards against silently signing a transition whose committed | ||
| /// document id does not match the entropy bound into the create transition. | ||
| /// Callers that want id auto-generation should use the legacy | ||
| /// [`PutDocument::put_to_platform`] trait method, which still accepts | ||
| /// `entropy = None` and rewrites the document id before signing. | ||
| pub(crate) fn ensure_document_id_matches_entropy( | ||
| document: &Document, | ||
| document_type: DocumentTypeRef<'_>, | ||
| entropy: &[u8; 32], | ||
| ) -> Result<(), Error> { | ||
| let expected = derive_document_id_v0(document_type, &document.owner_id(), entropy); | ||
| if document.id() != expected { | ||
| return Err(Error::InvalidArgument(format!( | ||
| "document.id does not match \ | ||
| generate_document_id_v0(contract_id, owner_id, document_type_name, entropy); \ | ||
| expected {expected}, got {got}. \ | ||
| Either set document.id to the derived value before calling \ | ||
| build_signed_document_create_transition, or use the legacy \ | ||
| PutDocument::put_to_platform trait method which auto-generates \ | ||
| entropy and rewrites the document id when entropy is None.", | ||
| got = document.id() | ||
| ))); | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Strict create-path id check hard-pins to v0 derivation despite project-wide PlatformVersion dispatch
derive_document_id_v0 and ensure_document_id_matches_entropy are hard-pinned to Document::generate_document_id_v0. The helper's doc comment acknowledges this and instructs future readers to grep both names if the derivation ever changes. The risk is that this helper is the new public pre-broadcast gate for build_signed_document_create_transition (and thus prepareDocumentCreate), so if a future protocol version introduces a new id derivation, every caller on the strict path will start rejecting otherwise-valid creates locally as InvalidArgument rather than letting the consensus rules decide. Given the rest of the platform consistently dispatches on PlatformVersion, the more defensive shape is to branch internally on the SDK's current protocol version here instead of relying on a future grep. Not a defect today, but worth either adding the version dispatch now or adding a TODO that ties the migration to a specific consensus-version bump.
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 117-167: Strict create-path id check hard-pins to v0 derivation despite project-wide PlatformVersion dispatch
`derive_document_id_v0` and `ensure_document_id_matches_entropy` are hard-pinned to `Document::generate_document_id_v0`. The helper's doc comment acknowledges this and instructs future readers to grep both names if the derivation ever changes. The risk is that this helper is the new public pre-broadcast gate for `build_signed_document_create_transition` (and thus `prepareDocumentCreate`), so if a future protocol version introduces a new id derivation, every caller on the strict path will start rejecting otherwise-valid creates locally as `InvalidArgument` rather than letting the consensus rules decide. Given the rest of the platform consistently dispatches on `PlatformVersion`, the more defensive shape is to branch internally on the SDK's current protocol version here instead of relying on a future grep. Not a defect today, but worth either adding the version dispatch now or adding a TODO that ties the migration to a specific consensus-version bump.
| /// # Returns | ||
| /// | ||
| /// * `Self` - The updated builder | ||
| pub fn with_settings(mut self, settings: PutSettings) -> Self { | ||
| pub fn with_settings(mut self, mut settings: PutSettings) -> Self { | ||
| if self.user_fee_increase.is_none() { | ||
| self.user_fee_increase = settings.user_fee_increase; | ||
| } | ||
| if self.state_transition_creation_options.is_none() { | ||
| self.state_transition_creation_options = settings.state_transition_creation_options; | ||
| } | ||
| // Strip the fee/creation-options fields from the stored settings so | ||
| // the dedicated builder fields are the sole source of truth at | ||
| // sign time. The remainder of `settings` flows through unchanged. | ||
| settings.user_fee_increase = None; | ||
| settings.state_transition_creation_options = None; |
There was a problem hiding this comment.
🟡 Suggestion: with_settings body duplicated verbatim across create/replace/delete builders
The new with_settings implementation is identical line-for-line in create.rs, replace.rs, and delete.rs (extract user_fee_increase and state_transition_creation_options into dedicated fields only when None, clear those two PutSettings fields, store the remainder). The behavioral contract is non-trivial (explicit setter wins, single source of truth, preserve other fields) and is currently maintained by copying the same code into three files plus three nearly-identical test suites. The failure mode this invites is divergence — someone adding a new PutSettings field that needs the same treatment, or fixing a bug in one but not the others. Extract this into a single helper (e.g. an inherent fn split_dedicated_fields(self) -> (Option<UserFeeIncrease>, Option<StateTransitionCreationOptions>, PutSettings) on PutSettings) so the three builder methods reduce to one or two lines that call it. The test tables can likewise be parameterized.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 123-137: `with_settings` body duplicated verbatim across create/replace/delete builders
The new `with_settings` implementation is identical line-for-line in create.rs, replace.rs, and delete.rs (extract `user_fee_increase` and `state_transition_creation_options` into dedicated fields only when `None`, clear those two `PutSettings` fields, store the remainder). The behavioral contract is non-trivial (explicit setter wins, single source of truth, preserve other fields) and is currently maintained by copying the same code into three files plus three nearly-identical test suites. The failure mode this invites is divergence — someone adding a new `PutSettings` field that needs the same treatment, or fixing a bug in one but not the others. Extract this into a single helper (e.g. an inherent `fn split_dedicated_fields(self) -> (Option<UserFeeIncrease>, Option<StateTransitionCreationOptions>, PutSettings)` on `PutSettings`) so the three builder methods reduce to one or two lines that call it. The test tables can likewise be parameterized.
| /// Filter `UnsupportedFeatureError` entries out of a structure-validation | ||
| /// result and convert any remaining errors into [`Error`]. | ||
| /// | ||
| /// `UnsupportedFeatureError` only signals that structure validation is not yet | ||
| /// implemented for that state transition kind, so it must never mask a real | ||
| /// validation failure. If the result becomes empty after filtering we treat it | ||
| /// as a no-op pass. | ||
| fn map_validation_result(result: SimpleConsensusValidationResult) -> Result<(), Error> { | ||
| if result.is_valid() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let real_errors: Vec<ConsensusError> = result | ||
| .errors | ||
| .into_iter() | ||
| .filter(|e| !is_unsupported_feature_error(e)) | ||
| .collect(); | ||
|
|
||
| if real_errors.is_empty() { | ||
| Ok(()) | ||
| } else { | ||
| Err(SimpleConsensusValidationResult::new_with_errors(real_errors).into()) | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: UnsupportedFeatureError filter assumes the variant is only a "not implemented" placeholder
map_validation_result filters every UnsupportedFeatureError out of mixed structure-validation results and treats an all-unsupported result as Ok. That is correct for identity-transition arms in rs-dpp/src/state_transition/mod.rs:1418-1426, but DPP also uses UnsupportedFeatureError as a legitimate consensus rejection — e.g. TokenConfigUpdateTransition::validate_structure_v0 rejects perpetual-distribution changes with UnsupportedFeatureError::new("of changing perpetual distribution", ...). Now that this helper is pub and called from many transition paths (transfer, vote, withdrawal, put_contract, purchase_document, etc.), a future BatchTransition that mixes a document op with an unsupported token-config update would have its real rejection silently filtered and only surface server-side after broadcast. Within this PR's scope the prepare flows only build single-document batches, so the concern does not materialize today — but the filter should either be tightened to acknowledge the second meaning or narrowed to the specific transition kinds where it's known safe (identity-based STs).
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 17-40: `UnsupportedFeatureError` filter assumes the variant is only a "not implemented" placeholder
`map_validation_result` filters every `UnsupportedFeatureError` out of mixed structure-validation results and treats an all-unsupported result as `Ok`. That is correct for identity-transition arms in `rs-dpp/src/state_transition/mod.rs:1418-1426`, but DPP also uses `UnsupportedFeatureError` as a legitimate consensus rejection — e.g. `TokenConfigUpdateTransition::validate_structure_v0` rejects perpetual-distribution changes with `UnsupportedFeatureError::new("of changing perpetual distribution", ...)`. Now that this helper is `pub` and called from many transition paths (transfer, vote, withdrawal, put_contract, purchase_document, etc.), a future `BatchTransition` that mixes a document op with an unsupported token-config update would have its real rejection silently filtered and only surface server-side after broadcast. Within this PR's scope the prepare flows only build single-document batches, so the concern does not materialize today — but the filter should either be tightened to acknowledge the second meaning or narrowed to the specific transition kinds where it's known safe (identity-based STs).
| ## Compatibility notes | ||
|
|
||
| - `DocumentDeleteTransitionBuilder::with_settings(settings).sign(...)` now honors `settings.user_fee_increase` and `settings.state_transition_creation_options` when those builder fields were not set directly. Native rs-sdk callers that passed delete transition fee/creation options through `PutSettings` may now see those intended values take effect on the wire instead of being silently dropped. | ||
| - `DocumentCreateTransitionBuilder` and `DocumentReplaceTransitionBuilder` apply the same rule as the delete builder: `with_settings(settings).sign_with_nonce(...)` (and `.sign(...)`) now honor `settings.user_fee_increase` and `settings.state_transition_creation_options` when those builder fields were not set directly. Explicit `with_user_fee_increase` / `with_state_transition_creation_options` calls still win regardless of order. Native callers that previously passed these via `PutSettings` should expect them to take effect on the wire. | ||
| - `DocumentDeleteTransitionBuilder::sign` now rolls back the allocated identity-contract nonce on pre-broadcast failure (matching `DocumentCreateTransitionBuilder::sign` / `DocumentReplaceTransitionBuilder::sign`). `DocumentDeleteTransitionBuilder::sign_with_nonce` now also runs `ensure_valid_state_transition_structure` before returning, so direct builder users get the same pre-broadcast guarantees as the shared `build_signed_document_delete_transition` helper. | ||
| - `ensure_valid_state_transition_structure` (used by the wasm-sdk `prepareDocument*` flows and the rs-sdk document create/replace/delete builders) filters `UnsupportedFeatureError` entries out of mixed structure-validation results before surfacing the remaining errors. This intentionally lets identity-based state transitions, which DPP's structure validator still reports as `UnsupportedFeatureError`, through to broadcast where execution-time validation runs. Callers that previously relied on `UnsupportedFeatureError` surfacing through this path will no longer see those entries — only the real validation failures. | ||
|
|
There was a problem hiding this comment.
🟡 Suggestion: rs-sdk compatibility note omits the new id-vs-entropy invariant for native callers
The compatibility section calls out the new with_settings pass-through behavior and the delete-builder rollback + structure-validation additions, but does not mention the new id-vs-entropy invariant enforced inside DocumentCreateTransitionBuilder::sign_with_nonce (create.rs:243-279) and PutDocument::put_to_platform (put_document.rs:704-733). Native rs-sdk callers that previously built a DocumentCreateTransitionBuilder, or called put_to_platform with entropy = Some(..), with a hand-picked document.id that did not match Document::generate_document_id_v0(...) will now see Error::InvalidArgument instead of a silently signed transition. The README is the durable reference for external consumers and should call this out alongside the other create/replace/delete behavior changes.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/README.md`:
- [SUGGESTION] lines 49-55: rs-sdk compatibility note omits the new id-vs-entropy invariant for native callers
The compatibility section calls out the new `with_settings` pass-through behavior and the delete-builder rollback + structure-validation additions, but does not mention the new id-vs-entropy invariant enforced inside `DocumentCreateTransitionBuilder::sign_with_nonce` (create.rs:243-279) and `PutDocument::put_to_platform` (put_document.rs:704-733). Native rs-sdk callers that previously built a `DocumentCreateTransitionBuilder`, or called `put_to_platform` with `entropy = Some(..)`, with a hand-picked `document.id` that did not match `Document::generate_document_id_v0(...)` will now see `Error::InvalidArgument` instead of a silently signed transition. The README is the durable reference for external consumers and should call this out alongside the other create/replace/delete behavior changes.
| document_state_transition_entropy: Option<[u8; 32]>, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| build_signed_document_create_or_replace_transition_legacy( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| ) | ||
| .await | ||
| } | ||
|
|
||
| /// Private implementation backing the deprecated public legacy dispatcher. | ||
| /// | ||
| /// Internal strict helpers route through this private entry point so | ||
| /// in-tree call sites do not trigger the public deprecation warning. | ||
| #[allow(clippy::too_many_arguments)] | ||
| async fn build_signed_document_create_or_replace_transition_legacy<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| document_state_transition_entropy: Option<[u8; 32]>, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| // Reject the always-invalid `Some(0)` revision before allocating any | ||
| // nonce. Strict create/replace intent validation is the job of the | ||
| // dedicated helpers below. | ||
| ensure_revision_nonzero(document.revision())?; | ||
|
|
||
| let owner_id = document.owner_id(); | ||
| let contract_id = document_type.data_contract_id(); | ||
| let new_identity_contract_nonce = sdk | ||
| .get_identity_contract_nonce(owner_id, contract_id, true, settings) | ||
| .await?; | ||
|
|
||
| let result = build_and_sign_create_or_replace_after_nonce( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| identity_public_key, | ||
| token_payment_info, |
There was a problem hiding this comment.
💬 Nitpick: _legacy private indirection has only one caller — the deprecated public wrapper
build_signed_document_create_or_replace_transition_legacy is documented as existing so that internal strict helpers can route through it without tripping the public deprecation warning. In the tree, however, the only caller of _legacy is the public deprecated wrapper itself. The strict create/replace helpers added in this PR dispatch into build_and_sign_create_after_nonce / build_and_sign_replace_after_nonce instead. That leaves the indirection as dead architecture — the deprecated public function could inline the impl, and the justification on _legacy should be removed or replaced with the actual reason (e.g. a specific anticipated future caller).
source: ['claude']
| ) -> Result<DocumentCreateResult, Error> { | ||
| let platform_version = self.version(); | ||
| // Destructure so we can move builder-owned fields (notably the | ||
| // `StateTransitionCreationOptions`, which is not necessarily Clone) | ||
| // into the effective settings without an extra copy. | ||
| let DocumentCreateTransitionBuilder { | ||
| data_contract, | ||
| document_type_name, | ||
| document, | ||
| document_state_transition_entropy, | ||
| token_payment_info, | ||
| settings, | ||
| user_fee_increase, | ||
| state_transition_creation_options, | ||
| } = create_document_transition_builder; | ||
|
|
||
| let put_settings = create_document_transition_builder.settings; | ||
| // Preserve broadcast-time settings (request_settings, wait_timeout, | ||
| // identity_nonce_stale_time_s) by keeping the original builder | ||
| // settings around for the broadcast call. The strict helper gets | ||
| // an `effective` clone that overlays the builder-specific | ||
| // user_fee_increase / state_transition_creation_options fields. | ||
| let broadcast_settings = settings; | ||
| let mut effective_settings = settings.unwrap_or_default(); | ||
| if let Some(ufi) = user_fee_increase { | ||
| effective_settings.user_fee_increase = Some(ufi); | ||
| } | ||
| if state_transition_creation_options.is_some() { | ||
| effective_settings.state_transition_creation_options = | ||
| state_transition_creation_options; | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Comment claims StateTransitionCreationOptions may not be Clone — but it is Copy
The destructure comment in Sdk::document_create (and the symmetric block in Sdk::document_replace at replace.rs:407-432) justifies destructuring with "notably the StateTransitionCreationOptions, which is not necessarily Clone." PutSettings is #[derive(Debug, Clone, Copy, Default)] and one of its fields is Option<StateTransitionCreationOptions>, which means StateTransitionCreationOptions is also Copy (verified: packages/rs-dpp/.../methods/mod.rs:50 declares #[derive(Debug, Clone, Copy, Default, Eq, PartialEq)] pub struct StateTransitionCreationOptions). The destructure itself is fine, but the stated rationale will mislead a future reader. Drop the parenthetical or replace it with the real reason (e.g., "so we can read the user_fee_increase / state_transition_creation_options overrides while reusing settings as the broadcast settings").
source: ['claude']
| @@ -98,12 +123,28 @@ impl DocumentCreateTransitionBuilder { | |||
| /// # Returns | |||
| /// | |||
| /// * `Self` - The updated builder | |||
| pub fn with_settings(mut self, settings: PutSettings) -> Self { | |||
| pub fn with_settings(mut self, mut settings: PutSettings) -> Self { | |||
| if self.user_fee_increase.is_none() { | |||
| self.user_fee_increase = settings.user_fee_increase; | |||
| } | |||
| if self.state_transition_creation_options.is_none() { | |||
| self.state_transition_creation_options = settings.state_transition_creation_options; | |||
| } | |||
| // Strip the fee/creation-options fields from the stored settings so | |||
| // the dedicated builder fields are the sole source of truth at | |||
| // sign time. The remainder of `settings` flows through unchanged. | |||
| settings.user_fee_increase = None; | |||
| settings.state_transition_creation_options = None; | |||
There was a problem hiding this comment.
💬 Nitpick: Revision and id-entropy validators run twice on the happy sign() path
DocumentCreateTransitionBuilder::sign runs ensure_revision_for_create and ensure_document_id_matches_entropy before allocating a nonce, then calls self.sign_with_nonce(...) which runs the same two validators again. Functionally fine (pure validators) but it costs an extra document_type lookup, revision check, and id derivation on every successful sign call. Either drop the validators from sign_with_nonce and document the precondition (callers using the pre-allocated-nonce variant must validate themselves) or factor a private sign_with_nonce_unchecked and have sign call it after its own up-front validation. The same double-validation pattern exists in replace.rs.
source: ['claude']
| pub(crate) async fn rollback_identity_contract_nonce( | ||
| &self, | ||
| identity_id: Identifier, | ||
| contract_id: Identifier, | ||
| allocated_nonce: IdentityNonce, | ||
| ) { | ||
| if allocated_nonce == 0 { | ||
| // Nothing to roll back; bumping never produces 0 (it starts at 1). | ||
| return; | ||
| } | ||
| let key = IdentityContractPair { | ||
| identity_id, | ||
| contract_id, | ||
| }; | ||
| let mut guard = self.contract_nonces.lock().await; | ||
| if let Some(entry) = guard.peek_mut(&key) { | ||
| if entry.current_nonce == allocated_nonce { | ||
| entry.current_nonce = allocated_nonce - 1; | ||
| tracing::trace!( | ||
| identity_id = %identity_id, | ||
| contract_id = %contract_id, | ||
| allocated_nonce, | ||
| "rolled back identity-contract nonce after local pre-broadcast failure" | ||
| ); | ||
| } else { | ||
| tracing::trace!( | ||
| identity_id = %identity_id, | ||
| contract_id = %contract_id, | ||
| allocated_nonce, | ||
| cached_nonce = entry.current_nonce, | ||
| "skipped identity-contract nonce rollback: cache moved past allocated nonce" | ||
| ); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Document why rollback_identity_contract_nonce does not also reset last_fetched_platform_nonce
The rollback decrements entry.current_nonce but intentionally leaves entry.last_fetched_platform_nonce and entry.last_fetch_timestamp untouched. This is correct — rollback is a local correction, not a re-sync, and touching the drift counter would mask real drift. But after a long-running session that has bumped the cache near MAX_MISSING_IDENTITY_REVISIONS from last_fetched_platform_nonce, a rollback can leave the very next allocation hitting the drift-triggered platform refetch in get_or_fetch_nonce even though the rollback effectively "freed" the slot. A one-line comment near the rollback explaining the intent will prevent a future reader from "fixing" it by also bumping last_fetched_platform_nonce.
source: ['claude']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The PR adds well-structured two-phase prepare APIs with conditional nonce rollback and stricter intent validation. The most pressing concern is a JS unit test that asserts the old revision-0 error message and will fail under the new dedicated message. Other notable issues: a typed→generic error-conversion regression in mixed structure validation, partial WASM trust-boundary hardening (legacy documentCreate/documentReplace and inner paymentTokenContractId/identityKey/signer still go through __wbg_ptr extraction), and extract_prepare_document structurally rebuilding documents while hard-coding creator_id: None.
Reviewed commit: 343f888
🔴 1 blocking | 🟡 5 suggestion(s) | 💬 3 nitpick(s)
1 additional finding
💬 nitpick: Bincode encoding after the helper returns has no rollback path
packages/rs-sdk-ffi/src/document/delete.rs (lines 174-196)
build_signed_document_delete_transition consumes its internal rollback handle on success, then this FFI runs bincode::encode_to_vec(&state_transition, …). The comment correctly notes that encoding StateTransition into a Vec<u8> is effectively infallible today, so a failure here only leaves the local identity-contract nonce cache advanced by one until the next refresh — no consensus impact. However, the previous in-house sign+manual-rollback site protected against exactly this drift, and a future fallible Encode impl (e.g. tokens/shielded payloads adding length-prefix bounds) silently turns this into an observable spurious-nonce-rejection from the FFI consumer's perspective. Either return the allocated nonce alongside the transition so this site can call sdk.rollback_identity_contract_nonce(...) on serialization failure, or fold the bincode step into a sibling helper that drops the rollback handle only after a successful encode.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [BLOCKING] lines 403-415: documentCreate revision-0 unit test asserts an error message the new code never emits
This test passes `revision: 0` and expects the message to match `/documentReplace/`. `documentCreate` now runs `ensure_document_create_revision(...)` first, which for `Some(0)` returns the dedicated `"revision 0 is invalid for both create and replace"` message and deliberately omits the sibling-API name (see `ensure_document_create_revision` at packages/wasm-sdk/src/state_transitions/document.rs:1650 and the matching Rust test at :1745). The JS suite will fail the moment it runs, and it no longer covers the new revision-0 behavior introduced by this PR.
In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 49-68: Mixed structure-validation errors are downgraded from typed ConsensusError to ProtocolError::Generic
When `validate_structure` returns a mix of `UnsupportedFeatureError` and a real failure, `map_validation_result` joins every error's `to_string()` into a single message wrapped in `ProtocolError::Generic`. The default `From<SimpleConsensusValidationResult> for Error` conversion would have preserved at least the first concrete `ConsensusError`, so Rust/WASM/FFI callers could pattern-match on the variant. With the new code the entire mixed case becomes an opaque string, which forces text matching — a real API regression now that `Error::InvalidArgument` is plumbed through elsewhere specifically to avoid that pattern. Preserve the typed errors (e.g. either keep the first non-unsupported `ConsensusError` as the primary `Error` and attach the rendered tail as context, or wrap the filtered list in a fresh `SimpleConsensusValidationResult` and reuse the existing `From` conversion).
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 309-310: Legacy documentCreate/documentReplace still extract `document` via pointer-trusting path
`DocumentWasm::try_from_options(&options, "document")` resolves to `impl_try_from_js_value!(DocumentWasm, "Document")` (packages/wasm-dpp2/src/data_contract/document/model.rs:769) → `generic_of_js_val` (packages/wasm-dpp2/src/utils.rs:114–141), which checks the spoofable `__type` string and then performs `unsafe { T::ref_from_abi(__wbg_ptr) }` on a caller-controlled `u32`. This PR's `extract_prepare_document`/`extract_delete_identifiers` exist precisely to avoid that path on untrusted JS, but the same `try_from_options` call remains on `documentCreate` (this line), `documentReplace` (:452), `documentTransfer` (:1058), `documentPurchase` (:1188), and `documentSetPrice` (:1308). A forged `{ __type: "Document", __wbg_ptr: <int> }` can still trap the WASM module or alias an unrelated live `Document`. Not introduced by this PR, but the new hardening sets the precedent — applying `extract_prepare_document` to the remaining one-shot entry points (or documenting why those callers are considered trusted) keeps the boundary consistent.
- [SUGGESTION] lines 174-196: tokenPaymentInfo hardening leaks back into the pointer path via paymentTokenContractId
`try_from_options_optional_token_payment_info` carefully avoids `TokenPaymentInfoWasm::try_from(&JsValue)` and instead copies public fields into a fresh options bag, then calls `TokenPaymentInfoWasm::constructor(...)`. That constructor parses `paymentTokenContractId` with `try_from_options_optional::<IdentifierWasm>` (packages/wasm-dpp2/src/state_transitions/batch/token_payment_info.rs:96-97), which goes through `IdentifierWasm::TryFrom<JsValue>` → `to_wasm::<IdentifierWasm>("Identifier")` → `generic_of_js_val` (packages/wasm-dpp2/src/identifier.rs:114-125, packages/wasm-dpp2/src/utils.rs:114-141). The first branch of that converter checks `__type === "Identifier"` and then dereferences `__wbg_ptr`. A forged `TokenPaymentInfo` with `paymentTokenContractId: { __type: "Identifier", __wbg_ptr: <int> }` therefore still smuggles an arbitrary pointer through, defeating the wrapper-level hardening. Read `paymentTokenContractId` via `extract_identifier_property` (which is already in this file and only uses string/byte/`toBytes/toString/toJSON` paths) before forwarding it to the constructor.
- [SUGGESTION] lines 733-737: Prepare APIs still trust `__wbg_ptr` for identityKey and signer
`prepare_document_create` (lines 733-737) and the same pair in `prepare_document_replace` (:836-840) and `prepare_document_delete` (:939-943) harden `document` but still call `IdentityPublicKeyWasm::try_from_options` and `IdentitySignerWasm::try_from_options`. Both go through `impl_try_from_js_value!` → `IntoWasm::to_wasm` → `generic_of_js_val`, which dereferences `__wbg_ptr` after only verifying a spoofable `__type`. The threat model that motivates `extract_prepare_document` applies at least as strongly to `signer` (holds private-key material) and `identityKey`. Either harden the two inputs analogously (e.g. require real class instances detected via a non-pointer discriminator) or add a module-level note explicitly carving these out as trusted-producer-only and explaining why structural extraction is not viable for opaque signer state.
- [SUGGESTION] lines 1588-1614: extract_prepare_document silently zeros `creator_id` and drops fields not enumerated
The helper rebuilds `Document::V0` from enumerated public getters but hard-codes `creator_id: None` (line 1605) because wasm-dpp2's `Document` does not expose a getter. For plain-object input the caller MAY pass `creatorId`, and it is silently dropped; for class-instance input the value is forced to `None` regardless of the original. Harmless for v0 create transitions today (creator_id is not consumed by `new_document_creation_transition_from_document` / `new_document_replacement_transition_from_document`), but the boundary-hardening rationale ("read every field via the public surface") means any future field added to `DocumentV0` or to wasm-dpp2's `Document` getters silently disappears here with no compile error. Either add a `creatorId` getter on wasm-dpp2's `Document` and read it, or destructure `DocumentV0` with named fields (no `..`) so adding a new field forces this code to be revisited; at minimum document the dropped fields on the TS `PrepareDocument*Options.document` interface.
| it('documentCreate rejects a document with revision 0', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument({ revision: 0 }); | ||
|
|
||
| try { | ||
| await client.documentCreate({ document, identityKey, signer }); | ||
| expect.fail('expected documentCreate to reject revision 0'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision/i); | ||
| expect(e.message).to.match(/documentReplace/); | ||
| } |
There was a problem hiding this comment.
🔴 Blocking: documentCreate revision-0 unit test asserts an error message the new code never emits
This test passes revision: 0 and expects the message to match /documentReplace/. documentCreate now runs ensure_document_create_revision(...) first, which for Some(0) returns the dedicated "revision 0 is invalid for both create and replace" message and deliberately omits the sibling-API name (see ensure_document_create_revision at packages/wasm-sdk/src/state_transitions/document.rs:1650 and the matching Rust test at :1745). The JS suite will fail the moment it runs, and it no longer covers the new revision-0 behavior introduced by this PR.
💡 Suggested change
| it('documentCreate rejects a document with revision 0', async () => { | |
| const { signer, identityKey } = buildSigner(); | |
| const document = buildDocument({ revision: 0 }); | |
| try { | |
| await client.documentCreate({ document, identityKey, signer }); | |
| expect.fail('expected documentCreate to reject revision 0'); | |
| } catch (e) { | |
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | |
| expect(e.name).to.equal('InvalidArgument'); | |
| expect(e.message).to.match(/revision/i); | |
| expect(e.message).to.match(/documentReplace/); | |
| } | |
| } catch (e) { | |
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | |
| expect(e.name).to.equal('InvalidArgument'); | |
| expect(e.message).to.match(/revision 0 is invalid for both create and replace/); | |
| expect(e.message).to.not.match(/documentReplace/); | |
| expect(e.message).to.not.match(/documentCreate/); | |
| } |
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [BLOCKING] lines 403-415: documentCreate revision-0 unit test asserts an error message the new code never emits
This test passes `revision: 0` and expects the message to match `/documentReplace/`. `documentCreate` now runs `ensure_document_create_revision(...)` first, which for `Some(0)` returns the dedicated `"revision 0 is invalid for both create and replace"` message and deliberately omits the sibling-API name (see `ensure_document_create_revision` at packages/wasm-sdk/src/state_transitions/document.rs:1650 and the matching Rust test at :1745). The JS suite will fail the moment it runs, and it no longer covers the new revision-0 behavior introduced by this PR.
| // Mixed `UnsupportedFeatureError` + real-error case. The default | ||
| // `From<SimpleConsensusValidationResult> for Error` conversion only | ||
| // keeps the first error and would therefore silently lose every | ||
| // remaining entry (including the unsupported ones, which in this | ||
| // shape are *not* "not implemented" placeholders but legitimate | ||
| // "feature unsupported on this platform version" rejections). | ||
| // | ||
| // Render every error into the message so callers see the full | ||
| // diagnostic set, then wrap as a `ProtocolError::Generic` so the | ||
| // returned `Error` still surfaces as a protocol-layer failure. | ||
| let rendered = result | ||
| .errors | ||
| .iter() | ||
| .map(|e| e.to_string()) | ||
| .collect::<Vec<_>>() | ||
| .join("; "); | ||
| Err(Error::Protocol(ProtocolError::Generic(format!( | ||
| "state transition structure validation failed: {rendered}" | ||
| )))) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Mixed structure-validation errors are downgraded from typed ConsensusError to ProtocolError::Generic
When validate_structure returns a mix of UnsupportedFeatureError and a real failure, map_validation_result joins every error's to_string() into a single message wrapped in ProtocolError::Generic. The default From<SimpleConsensusValidationResult> for Error conversion would have preserved at least the first concrete ConsensusError, so Rust/WASM/FFI callers could pattern-match on the variant. With the new code the entire mixed case becomes an opaque string, which forces text matching — a real API regression now that Error::InvalidArgument is plumbed through elsewhere specifically to avoid that pattern. Preserve the typed errors (e.g. either keep the first non-unsupported ConsensusError as the primary Error and attach the rendered tail as context, or wrap the filtered list in a fresh SimpleConsensusValidationResult and reuse the existing From conversion).
source: ['claude', 'codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 49-68: Mixed structure-validation errors are downgraded from typed ConsensusError to ProtocolError::Generic
When `validate_structure` returns a mix of `UnsupportedFeatureError` and a real failure, `map_validation_result` joins every error's `to_string()` into a single message wrapped in `ProtocolError::Generic`. The default `From<SimpleConsensusValidationResult> for Error` conversion would have preserved at least the first concrete `ConsensusError`, so Rust/WASM/FFI callers could pattern-match on the variant. With the new code the entire mixed case becomes an opaque string, which forces text matching — a real API regression now that `Error::InvalidArgument` is plumbed through elsewhere specifically to avoid that pattern. Preserve the typed errors (e.g. either keep the first non-unsupported `ConsensusError` as the primary `Error` and attach the rendered tail as context, or wrap the filtered list in a fresh `SimpleConsensusValidationResult` and reuse the existing `From` conversion).
| let document_wasm = DocumentWasm::try_from_options(&options, "document")?; | ||
| let document: Document = document_wasm.clone().into(); |
There was a problem hiding this comment.
🟡 Suggestion: Legacy documentCreate/documentReplace still extract document via pointer-trusting path
DocumentWasm::try_from_options(&options, "document") resolves to impl_try_from_js_value!(DocumentWasm, "Document") (packages/wasm-dpp2/src/data_contract/document/model.rs:769) → generic_of_js_val (packages/wasm-dpp2/src/utils.rs:114–141), which checks the spoofable __type string and then performs unsafe { T::ref_from_abi(__wbg_ptr) } on a caller-controlled u32. This PR's extract_prepare_document/extract_delete_identifiers exist precisely to avoid that path on untrusted JS, but the same try_from_options call remains on documentCreate (this line), documentReplace (:452), documentTransfer (:1058), documentPurchase (:1188), and documentSetPrice (:1308). A forged { __type: "Document", __wbg_ptr: <int> } can still trap the WASM module or alias an unrelated live Document. Not introduced by this PR, but the new hardening sets the precedent — applying extract_prepare_document to the remaining one-shot entry points (or documenting why those callers are considered trusted) keeps the boundary consistent.
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 309-310: Legacy documentCreate/documentReplace still extract `document` via pointer-trusting path
`DocumentWasm::try_from_options(&options, "document")` resolves to `impl_try_from_js_value!(DocumentWasm, "Document")` (packages/wasm-dpp2/src/data_contract/document/model.rs:769) → `generic_of_js_val` (packages/wasm-dpp2/src/utils.rs:114–141), which checks the spoofable `__type` string and then performs `unsafe { T::ref_from_abi(__wbg_ptr) }` on a caller-controlled `u32`. This PR's `extract_prepare_document`/`extract_delete_identifiers` exist precisely to avoid that path on untrusted JS, but the same `try_from_options` call remains on `documentCreate` (this line), `documentReplace` (:452), `documentTransfer` (:1058), `documentPurchase` (:1188), and `documentSetPrice` (:1308). A forged `{ __type: "Document", __wbg_ptr: <int> }` can still trap the WASM module or alias an unrelated live `Document`. Not introduced by this PR, but the new hardening sets the precedent — applying `extract_prepare_document` to the remaining one-shot entry points (or documenting why those callers are considered trusted) keeps the boundary consistent.
| let token_payment_info = match class_type.as_str() { | ||
| "TokenPaymentInfo" => TokenPaymentInfoWasm::constructor( | ||
| token_payment_info_options_from_public_fields(&token_payment_info_value)?, | ||
| ) | ||
| .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?, | ||
| // Plain object path: no `__type` getter is set up, so | ||
| // `get_class_type` returns `Ok("")` (empty string default in | ||
| // `JsValue::as_string().unwrap_or_default()`). Treat the empty | ||
| // string the same as "no class marker present". | ||
| "" => TokenPaymentInfoWasm::constructor( | ||
| token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(), | ||
| ) | ||
| .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?, | ||
| other => { | ||
| return Err(WasmSdkError::invalid_argument(format!( | ||
| "tokenPaymentInfo must be a plain DocumentTokenPaymentInfo options object \ | ||
| or a TokenPaymentInfo instance, got class '{other}'" | ||
| ))); | ||
| } | ||
| }; | ||
|
|
||
| Ok(Some(token_payment_info.into())) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: tokenPaymentInfo hardening leaks back into the pointer path via paymentTokenContractId
try_from_options_optional_token_payment_info carefully avoids TokenPaymentInfoWasm::try_from(&JsValue) and instead copies public fields into a fresh options bag, then calls TokenPaymentInfoWasm::constructor(...). That constructor parses paymentTokenContractId with try_from_options_optional::<IdentifierWasm> (packages/wasm-dpp2/src/state_transitions/batch/token_payment_info.rs:96-97), which goes through IdentifierWasm::TryFrom<JsValue> → to_wasm::<IdentifierWasm>("Identifier") → generic_of_js_val (packages/wasm-dpp2/src/identifier.rs:114-125, packages/wasm-dpp2/src/utils.rs:114-141). The first branch of that converter checks __type === "Identifier" and then dereferences __wbg_ptr. A forged TokenPaymentInfo with paymentTokenContractId: { __type: "Identifier", __wbg_ptr: <int> } therefore still smuggles an arbitrary pointer through, defeating the wrapper-level hardening. Read paymentTokenContractId via extract_identifier_property (which is already in this file and only uses string/byte/toBytes/toString/toJSON paths) before forwarding it to the constructor.
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 174-196: tokenPaymentInfo hardening leaks back into the pointer path via paymentTokenContractId
`try_from_options_optional_token_payment_info` carefully avoids `TokenPaymentInfoWasm::try_from(&JsValue)` and instead copies public fields into a fresh options bag, then calls `TokenPaymentInfoWasm::constructor(...)`. That constructor parses `paymentTokenContractId` with `try_from_options_optional::<IdentifierWasm>` (packages/wasm-dpp2/src/state_transitions/batch/token_payment_info.rs:96-97), which goes through `IdentifierWasm::TryFrom<JsValue>` → `to_wasm::<IdentifierWasm>("Identifier")` → `generic_of_js_val` (packages/wasm-dpp2/src/identifier.rs:114-125, packages/wasm-dpp2/src/utils.rs:114-141). The first branch of that converter checks `__type === "Identifier"` and then dereferences `__wbg_ptr`. A forged `TokenPaymentInfo` with `paymentTokenContractId: { __type: "Identifier", __wbg_ptr: <int> }` therefore still smuggles an arbitrary pointer through, defeating the wrapper-level hardening. Read `paymentTokenContractId` via `extract_identifier_property` (which is already in this file and only uses string/byte/`toBytes/toString/toJSON` paths) before forwarding it to the constructor.
| let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; | ||
| let identity_key: IdentityPublicKey = identity_key_wasm.into(); | ||
|
|
||
| // Extract signer from options | ||
| let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; |
There was a problem hiding this comment.
🟡 Suggestion: Prepare APIs still trust __wbg_ptr for identityKey and signer
prepare_document_create (lines 733-737) and the same pair in prepare_document_replace (:836-840) and prepare_document_delete (:939-943) harden document but still call IdentityPublicKeyWasm::try_from_options and IdentitySignerWasm::try_from_options. Both go through impl_try_from_js_value! → IntoWasm::to_wasm → generic_of_js_val, which dereferences __wbg_ptr after only verifying a spoofable __type. The threat model that motivates extract_prepare_document applies at least as strongly to signer (holds private-key material) and identityKey. Either harden the two inputs analogously (e.g. require real class instances detected via a non-pointer discriminator) or add a module-level note explicitly carving these out as trusted-producer-only and explaining why structural extraction is not viable for opaque signer state.
source: ['claude', 'codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 733-737: Prepare APIs still trust `__wbg_ptr` for identityKey and signer
`prepare_document_create` (lines 733-737) and the same pair in `prepare_document_replace` (:836-840) and `prepare_document_delete` (:939-943) harden `document` but still call `IdentityPublicKeyWasm::try_from_options` and `IdentitySignerWasm::try_from_options`. Both go through `impl_try_from_js_value!` → `IntoWasm::to_wasm` → `generic_of_js_val`, which dereferences `__wbg_ptr` after only verifying a spoofable `__type`. The threat model that motivates `extract_prepare_document` applies at least as strongly to `signer` (holds private-key material) and `identityKey`. Either harden the two inputs analogously (e.g. require real class instances detected via a non-pointer discriminator) or add a module-level note explicitly carving these out as trusted-producer-only and explaining why structural extraction is not viable for opaque signer state.
| let document = Document::V0(DocumentV0 { | ||
| id, | ||
| owner_id, | ||
| properties, | ||
| revision, | ||
| created_at, | ||
| updated_at, | ||
| transferred_at, | ||
| created_at_block_height, | ||
| updated_at_block_height, | ||
| transferred_at_block_height, | ||
| created_at_core_block_height, | ||
| updated_at_core_block_height, | ||
| transferred_at_core_block_height, | ||
| // `creator_id` is not part of the public `Document` getter surface | ||
| // exposed by wasm-dpp2, so we cannot copy it structurally. Leaving | ||
| // it `None` matches the `DocumentWasm` constructor's own default. | ||
| creator_id: None, | ||
| }); | ||
|
|
||
| Ok(DocumentWasm::new( | ||
| document, | ||
| contract_id, | ||
| document_type_name, | ||
| entropy, | ||
| )) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: extract_prepare_document silently zeros creator_id and drops fields not enumerated
The helper rebuilds Document::V0 from enumerated public getters but hard-codes creator_id: None (line 1605) because wasm-dpp2's Document does not expose a getter. For plain-object input the caller MAY pass creatorId, and it is silently dropped; for class-instance input the value is forced to None regardless of the original. Harmless for v0 create transitions today (creator_id is not consumed by new_document_creation_transition_from_document / new_document_replacement_transition_from_document), but the boundary-hardening rationale ("read every field via the public surface") means any future field added to DocumentV0 or to wasm-dpp2's Document getters silently disappears here with no compile error. Either add a creatorId getter on wasm-dpp2's Document and read it, or destructure DocumentV0 with named fields (no ..) so adding a new field forces this code to be revisited; at minimum document the dropped fields on the TS PrepareDocument*Options.document interface.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1588-1614: extract_prepare_document silently zeros `creator_id` and drops fields not enumerated
The helper rebuilds `Document::V0` from enumerated public getters but hard-codes `creator_id: None` (line 1605) because wasm-dpp2's `Document` does not expose a getter. For plain-object input the caller MAY pass `creatorId`, and it is silently dropped; for class-instance input the value is forced to `None` regardless of the original. Harmless for v0 create transitions today (creator_id is not consumed by `new_document_creation_transition_from_document` / `new_document_replacement_transition_from_document`), but the boundary-hardening rationale ("read every field via the public surface") means any future field added to `DocumentV0` or to wasm-dpp2's `Document` getters silently disappears here with no compile error. Either add a `creatorId` getter on wasm-dpp2's `Document` and read it, or destructure `DocumentV0` with named fields (no `..`) so adding a new field forces this code to be revisited; at minimum document the dropped fields on the TS `PrepareDocument*Options.document` interface.
| // Destructure so we can move builder-owned fields (notably the | ||
| // `StateTransitionCreationOptions`, which is not necessarily Clone) | ||
| // into the effective settings without an extra copy. | ||
| let DocumentCreateTransitionBuilder { | ||
| data_contract, |
There was a problem hiding this comment.
💬 Nitpick: Destructure rationale comment is incorrect: StateTransitionCreationOptions is Copy
The comment justifies destructuring with notably the StateTransitionCreationOptions, which is not necessarily Clone, but PutSettings (packages/rs-sdk/src/platform/transition/put_settings.rs:8) derives Copy and StateTransitionCreationOptions (packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs:50) derives Copy, Clone. The destructure does not avoid any clones; it's equivalent to plain field reads. The same comment appears in the analogous Sdk::document_replace entry in replace.rs. Update both comments or drop them — the destructure is fine for readability, just not for the stated reason.
source: ['claude']
| fn is_document_replace_revision(revision: Option<u64>) -> bool { | ||
| revision.is_some_and(|rev| rev > INITIAL_REVISION) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: is_document_replace_revision is only reachable through the deprecated legacy dispatcher
After the strict ensure_revision_for_create / ensure_revision_for_replace guards landed, the sole non-test caller of is_document_replace_revision is build_and_sign_create_or_replace_after_nonce (line 619), which itself is only used by build_signed_document_create_or_replace_transition_legacy (line 309), backing the #[deprecated] public entry point at line 281. When the deprecated legacy dispatcher is removed, this function becomes dead code with no compile-time signal. Mark it #[deprecated] alongside the legacy dispatcher (or inline it into build_and_sign_create_or_replace_after_nonce) so retiring the legacy entry point also retires this helper in one motion.
source: ['claude']
343f888 to
18a7df1
Compare
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified findings across the PR's two-phase document prepare APIs, strict create/replace helpers, and structural JS extraction. One blocking issue is real: the new structural extraction in extract_prepare_document routes properties through JSON normalization that erases Identifier/Bytes/BigInt typing, with no test coverage for byte-typed properties — and for one-shot documentCreate/documentReplace this is a regression vs the previous pointer-deref path that returned the in-Rust typed BTreeMap for fetched documents. Codex's blocking finding on identityKey/signer __wbg_ptr trust is dropped as an explicitly documented and project-wide accepted carve-out. Several smaller redundancies, an API-surface widening, deprecation hygiene, and a PR-description-vs-code mismatch round out the remaining suggestions/nitpicks.
Reviewed commit: 18a7df1
🔴 1 blocking | 🟡 2 suggestion(s) | 💬 6 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [BLOCKING] lines 1695-1697: extract_prepare_document drops Identifier/Bytes/BigInt typing on real Document instances
`extract_prepare_document` now reads `document.properties` via `with_serde_to_platform_value_map`, which goes through `js_value_to_json` → `normalize_js_value_for_json` (`packages/wasm-dpp2/src/serialization/conversions.rs:94`). That path converts `Uint8Array` → plain JS number arrays and `BigInt` → strings before round-tripping through `serde_wasm_bindgen` into `platform_value::Value`.
When the JS caller passes a real `Document` class instance (e.g. fetched from Platform and re-presented), the `properties` getter in `packages/wasm-dpp2/src/data_contract/document/model.rs:279` serializes typed properties via `platform_value_to_object` — `Identifier` becomes `Uint8Array`, `Bytes*` becomes `Uint8Array`, `U64` becomes `BigInt`. After the new normalization, those reconstruct as `Value::Array(Vec<Value::U64>)` and `Value::Text(String)` rather than `Value::Identifier([u8;32])` / `Value::Bytes(...)` / `Value::U64(_)`.
The previous one-shot `documentCreate`/`documentReplace` path used `DocumentWasm::try_from_options(&options, "document")?` (verified at base commit `c7cb8e6f5f`), which dereferenced the wasm-bindgen handle and returned the in-Rust typed `BTreeMap` intact. The new extractor is therefore a genuine behavior change for fetched documents and for any caller passing a `Document` instance whose schema has byte- or bigint-typed fields (DPNS / DashPay identifiers, contested resource IDs, etc.).
This change is now in the path for `documentCreate`, `documentReplace`, `documentTransfer`, `documentPurchase`, `documentSetPrice`, `prepareDocumentCreate`, and `prepareDocumentReplace`. The new unit tests in `packages/wasm-sdk/tests/unit/prepare-document.spec.ts` only exercise `{ message: 'hello' }`-style string properties, so the regression is not caught by CI.
Recommended fixes: (1) restore the trusted `DocumentWasm::try_from(&JsValue)` path when the input is a real `Document` instance (detected via `get_class_type(...) == "Document"`, mirroring the existing pattern in `extract_delete_identifiers` at line 1641+), and apply structural extraction only to plain-object inputs; or (2) route the structural path through a deserializer that preserves identifier/bytes typing rather than human-readable JSON. Either fix should be paired with unit tests that exercise an Identifier-typed property end-to-end.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 136-147: derive_document_id_v0 hard-pinned to v0 instead of using platform-version dispatch
`derive_document_id_v0` and every call site (`ensure_document_id_matches_entropy`, the legacy entropy fallback at line 194, the wasm-sdk fast-path `ensure_document_id_matches_entropy_fast`) hard-code `Document::generate_document_id_v0` on a consensus-critical path: this helper pre-rejects user transitions with `Error::InvalidArgument` before nonce allocation, and Platform enforces the same invariant.
The TODO comment (lines 126-135) acknowledges that any v1 derivation requires coordinated changes at every call site — that is exactly what platform-version dispatch (`platform_version.document_versions.derive_document_id`) is meant to centralize. A single `match version { 0 => ..., v => Err(UnknownVersionMismatch) }` here would catch the migration at compile/runtime instead of via a grep-and-pray TODO, and aligns with the project's own convention that versioned dispatch is mandatory for consensus-critical code.
In `packages/rs-sdk/src/platform/transition/put_settings.rs`:
- [SUGGESTION] lines 51-65: Second with_settings() call silently drops its fee_increase / state_transition_creation_options
`PutSettings::split_dedicated_fields` writes `user_fee_increase` / `state_transition_creation_options` into the dedicated builder fields **only if** those fields are currently `None`. The first `with_settings(s1)` populates them from `s1` and clears them out of the stored settings. A subsequent `with_settings(s2)` sees the dedicated fields are `Some(_)`, leaves them alone, then clears `s2.user_fee_increase` / `s2.state_transition_creation_options` — silently dropping any new fee/options carried by `s2` while overwriting the rest of the settings.
This is contrary to the natural reading of "with_settings replaces the settings", and the doc-comments on the three document builders only describe the explicit-setter-vs-settings precedence, not this first-wins ordering for fee/options. Either document the edge case explicitly on every `with_settings` impl, or make the second `with_settings` call overwrite the dedicated fields (the second call is itself an explicit setter for those fields).
| let properties = try_from_options_with(&document_js, "properties", |v| { | ||
| v.with_serde_to_platform_value_map() | ||
| })?; |
There was a problem hiding this comment.
🔴 Blocking: extract_prepare_document drops Identifier/Bytes/BigInt typing on real Document instances
extract_prepare_document now reads document.properties via with_serde_to_platform_value_map, which goes through js_value_to_json → normalize_js_value_for_json (packages/wasm-dpp2/src/serialization/conversions.rs:94). That path converts Uint8Array → plain JS number arrays and BigInt → strings before round-tripping through serde_wasm_bindgen into platform_value::Value.
When the JS caller passes a real Document class instance (e.g. fetched from Platform and re-presented), the properties getter in packages/wasm-dpp2/src/data_contract/document/model.rs:279 serializes typed properties via platform_value_to_object — Identifier becomes Uint8Array, Bytes* becomes Uint8Array, U64 becomes BigInt. After the new normalization, those reconstruct as Value::Array(Vec<Value::U64>) and Value::Text(String) rather than Value::Identifier([u8;32]) / Value::Bytes(...) / Value::U64(_).
The previous one-shot documentCreate/documentReplace path used DocumentWasm::try_from_options(&options, "document")? (verified at base commit c7cb8e6f5f), which dereferenced the wasm-bindgen handle and returned the in-Rust typed BTreeMap intact. The new extractor is therefore a genuine behavior change for fetched documents and for any caller passing a Document instance whose schema has byte- or bigint-typed fields (DPNS / DashPay identifiers, contested resource IDs, etc.).
This change is now in the path for documentCreate, documentReplace, documentTransfer, documentPurchase, documentSetPrice, prepareDocumentCreate, and prepareDocumentReplace. The new unit tests in packages/wasm-sdk/tests/unit/prepare-document.spec.ts only exercise { message: 'hello' }-style string properties, so the regression is not caught by CI.
Recommended fixes: (1) restore the trusted DocumentWasm::try_from(&JsValue) path when the input is a real Document instance (detected via get_class_type(...) == "Document", mirroring the existing pattern in extract_delete_identifiers at line 1641+), and apply structural extraction only to plain-object inputs; or (2) route the structural path through a deserializer that preserves identifier/bytes typing rather than human-readable JSON. Either fix should be paired with unit tests that exercise an Identifier-typed property end-to-end.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [BLOCKING] lines 1695-1697: extract_prepare_document drops Identifier/Bytes/BigInt typing on real Document instances
`extract_prepare_document` now reads `document.properties` via `with_serde_to_platform_value_map`, which goes through `js_value_to_json` → `normalize_js_value_for_json` (`packages/wasm-dpp2/src/serialization/conversions.rs:94`). That path converts `Uint8Array` → plain JS number arrays and `BigInt` → strings before round-tripping through `serde_wasm_bindgen` into `platform_value::Value`.
When the JS caller passes a real `Document` class instance (e.g. fetched from Platform and re-presented), the `properties` getter in `packages/wasm-dpp2/src/data_contract/document/model.rs:279` serializes typed properties via `platform_value_to_object` — `Identifier` becomes `Uint8Array`, `Bytes*` becomes `Uint8Array`, `U64` becomes `BigInt`. After the new normalization, those reconstruct as `Value::Array(Vec<Value::U64>)` and `Value::Text(String)` rather than `Value::Identifier([u8;32])` / `Value::Bytes(...)` / `Value::U64(_)`.
The previous one-shot `documentCreate`/`documentReplace` path used `DocumentWasm::try_from_options(&options, "document")?` (verified at base commit `c7cb8e6f5f`), which dereferenced the wasm-bindgen handle and returned the in-Rust typed `BTreeMap` intact. The new extractor is therefore a genuine behavior change for fetched documents and for any caller passing a `Document` instance whose schema has byte- or bigint-typed fields (DPNS / DashPay identifiers, contested resource IDs, etc.).
This change is now in the path for `documentCreate`, `documentReplace`, `documentTransfer`, `documentPurchase`, `documentSetPrice`, `prepareDocumentCreate`, and `prepareDocumentReplace`. The new unit tests in `packages/wasm-sdk/tests/unit/prepare-document.spec.ts` only exercise `{ message: 'hello' }`-style string properties, so the regression is not caught by CI.
Recommended fixes: (1) restore the trusted `DocumentWasm::try_from(&JsValue)` path when the input is a real `Document` instance (detected via `get_class_type(...) == "Document"`, mirroring the existing pattern in `extract_delete_identifiers` at line 1641+), and apply structural extraction only to plain-object inputs; or (2) route the structural path through a deserializer that preserves identifier/bytes typing rather than human-readable JSON. Either fix should be paired with unit tests that exercise an Identifier-typed property end-to-end.
| fn derive_document_id_v0( | ||
| document_type: DocumentTypeRef<'_>, | ||
| owner_id: &Identifier, | ||
| entropy: &[u8; 32], | ||
| ) -> Identifier { | ||
| Document::generate_document_id_v0( | ||
| &document_type.data_contract_id(), | ||
| owner_id, | ||
| document_type.name(), | ||
| entropy.as_slice(), | ||
| ) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: derive_document_id_v0 hard-pinned to v0 instead of using platform-version dispatch
derive_document_id_v0 and every call site (ensure_document_id_matches_entropy, the legacy entropy fallback at line 194, the wasm-sdk fast-path ensure_document_id_matches_entropy_fast) hard-code Document::generate_document_id_v0 on a consensus-critical path: this helper pre-rejects user transitions with Error::InvalidArgument before nonce allocation, and Platform enforces the same invariant.
The TODO comment (lines 126-135) acknowledges that any v1 derivation requires coordinated changes at every call site — that is exactly what platform-version dispatch (platform_version.document_versions.derive_document_id) is meant to centralize. A single match version { 0 => ..., v => Err(UnknownVersionMismatch) } here would catch the migration at compile/runtime instead of via a grep-and-pray TODO, and aligns with the project's own convention that versioned dispatch is mandatory for consensus-critical code.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 136-147: derive_document_id_v0 hard-pinned to v0 instead of using platform-version dispatch
`derive_document_id_v0` and every call site (`ensure_document_id_matches_entropy`, the legacy entropy fallback at line 194, the wasm-sdk fast-path `ensure_document_id_matches_entropy_fast`) hard-code `Document::generate_document_id_v0` on a consensus-critical path: this helper pre-rejects user transitions with `Error::InvalidArgument` before nonce allocation, and Platform enforces the same invariant.
The TODO comment (lines 126-135) acknowledges that any v1 derivation requires coordinated changes at every call site — that is exactly what platform-version dispatch (`platform_version.document_versions.derive_document_id`) is meant to centralize. A single `match version { 0 => ..., v => Err(UnknownVersionMismatch) }` here would catch the migration at compile/runtime instead of via a grep-and-pray TODO, and aligns with the project's own convention that versioned dispatch is mandatory for consensus-critical code.
| pub fn split_dedicated_fields( | ||
| mut self, | ||
| dedicated_user_fee_increase: &mut Option<UserFeeIncrease>, | ||
| dedicated_state_transition_creation_options: &mut Option<StateTransitionCreationOptions>, | ||
| ) -> Self { | ||
| if dedicated_user_fee_increase.is_none() { | ||
| *dedicated_user_fee_increase = self.user_fee_increase; | ||
| } | ||
| if dedicated_state_transition_creation_options.is_none() { | ||
| *dedicated_state_transition_creation_options = self.state_transition_creation_options; | ||
| } | ||
| self.user_fee_increase = None; | ||
| self.state_transition_creation_options = None; | ||
| self | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Second with_settings() call silently drops its fee_increase / state_transition_creation_options
PutSettings::split_dedicated_fields writes user_fee_increase / state_transition_creation_options into the dedicated builder fields only if those fields are currently None. The first with_settings(s1) populates them from s1 and clears them out of the stored settings. A subsequent with_settings(s2) sees the dedicated fields are Some(_), leaves them alone, then clears s2.user_fee_increase / s2.state_transition_creation_options — silently dropping any new fee/options carried by s2 while overwriting the rest of the settings.
This is contrary to the natural reading of "with_settings replaces the settings", and the doc-comments on the three document builders only describe the explicit-setter-vs-settings precedence, not this first-wins ordering for fee/options. Either document the edge case explicitly on every with_settings impl, or make the second with_settings call overwrite the dedicated fields (the second call is itself an explicit setter for those fields).
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_settings.rs`:
- [SUGGESTION] lines 51-65: Second with_settings() call silently drops its fee_increase / state_transition_creation_options
`PutSettings::split_dedicated_fields` writes `user_fee_increase` / `state_transition_creation_options` into the dedicated builder fields **only if** those fields are currently `None`. The first `with_settings(s1)` populates them from `s1` and clears them out of the stored settings. A subsequent `with_settings(s2)` sees the dedicated fields are `Some(_)`, leaves them alone, then clears `s2.user_fee_increase` / `s2.state_transition_creation_options` — silently dropping any new fee/options carried by `s2` while overwriting the rest of the settings.
This is contrary to the natural reading of "with_settings replaces the settings", and the doc-comments on the three document builders only describe the explicit-setter-vs-settings precedence, not this first-wins ordering for fee/options. Either document the edge case explicitly on every `with_settings` impl, or make the second `with_settings` call overwrite the dedicated fields (the second call is itself an explicit setter for those fields).
| { | ||
| let document_type = self | ||
| .data_contract | ||
| .document_type_for_name(&self.document_type_name) | ||
| .map_err(|e| Error::Protocol(e.into()))?; | ||
| ensure_revision_for_create(self.document.revision())?; | ||
| ensure_document_id_matches_entropy( | ||
| &self.document, | ||
| document_type, | ||
| &self.document_state_transition_entropy, | ||
| )?; | ||
| } | ||
|
|
||
| let owner_id = self.document.owner_id(); | ||
| let contract_id = self.data_contract.id(); | ||
| let identity_contract_nonce = sdk | ||
| .get_identity_contract_nonce( | ||
| self.document.owner_id(), | ||
| self.data_contract.id(), | ||
| true, | ||
| self.settings, | ||
| ) | ||
| .get_identity_contract_nonce(owner_id, contract_id, true, self.settings) | ||
| .await?; | ||
|
|
||
| match self | ||
| .sign_with_nonce( | ||
| identity_contract_nonce, | ||
| identity_public_key, | ||
| signer, | ||
| platform_version, | ||
| ) | ||
| .await | ||
| { | ||
| Ok(transition) => Ok(transition), | ||
| Err(err) => { | ||
| sdk.rollback_identity_contract_nonce( | ||
| owner_id, | ||
| contract_id, | ||
| identity_contract_nonce, | ||
| ) | ||
| .await; | ||
| Err(err) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Signs the document create transition using a pre-allocated | ||
| /// identity-contract nonce. | ||
| /// | ||
| /// This variant lets the caller separate nonce allocation from signing so | ||
| /// pre-broadcast failures can be rolled back by calling | ||
| /// [`Sdk::rollback_identity_contract_nonce`](crate::Sdk::rollback_identity_contract_nonce) | ||
| /// with the same `identity_contract_nonce`. The caller is responsible for | ||
| /// having obtained the nonce via | ||
| /// [`Sdk::get_identity_contract_nonce`](crate::Sdk::get_identity_contract_nonce) | ||
| /// with `bump_first = true` for the same `(owner_id, contract_id)` pair. | ||
| pub async fn sign_with_nonce( | ||
| &self, | ||
| identity_contract_nonce: IdentityNonce, | ||
| identity_public_key: &IdentityPublicKey, | ||
| signer: &impl Signer<IdentityPublicKey>, | ||
| platform_version: &PlatformVersion, | ||
| ) -> Result<StateTransition, Error> { | ||
| let document_type = self | ||
| .data_contract | ||
| .document_type_for_name(&self.document_type_name) | ||
| .map_err(|e| Error::Protocol(e.into()))?; | ||
|
|
||
| ensure_revision_for_create(self.document.revision())?; | ||
| ensure_document_id_matches_entropy( | ||
| &self.document, | ||
| document_type, | ||
| &self.document_state_transition_entropy, | ||
| )?; |
There was a problem hiding this comment.
💬 Nitpick: Builder sign() and sign_with_nonce() duplicate document_type lookup + revision + id-vs-entropy checks
DocumentCreateTransitionBuilder::sign performs document_type_for_name, ensure_revision_for_create, and ensure_document_id_matches_entropy at lines 186-196, and sign_with_nonce then repeats all three at lines 243-253. The same duplication exists in packages/rs-sdk/src/platform/documents/transitions/replace.rs (lines 179 and 231).
The pre-nonce-alloc check is the load-bearing one (so failed validation never bumps the nonce); the inner re-check only protects callers that invoke sign_with_nonce directly. The duplication is cheap but invites drift if one site is updated and the other isn't. Either drop the inner re-check now that the outer one is in place, or add a brief doc-comment on sign_with_nonce noting why the defensive re-check is intentional.
source: ['claude']
| async fn build_and_sign_replace_after_nonce<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| new_identity_contract_nonce: u64, | ||
| ) -> Result<StateTransition, Error> { | ||
| let put_settings = settings.unwrap_or_default(); | ||
| let transition = BatchTransition::new_document_replacement_transition_from_document( | ||
| document.clone(), | ||
| document_type.as_ref(), | ||
| identity_public_key, | ||
| new_identity_contract_nonce, | ||
| put_settings.user_fee_increase.unwrap_or_default(), | ||
| token_payment_info, | ||
| signer, | ||
| sdk.version(), | ||
| put_settings.state_transition_creation_options, | ||
| ) | ||
| .await?; | ||
| ensure_valid_state_transition_structure(&transition, sdk.version())?; | ||
| Ok(transition) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Asymmetric ownership: create takes Document by value, replace takes &Document and clones internally
build_and_sign_create_after_nonce (line 478) was deliberately refactored to take document: Document by value so the document is moved into BatchTransition::new_document_creation_transition_from_document without an extra clone (see the function doc-comment claiming "a single Document clone end-to-end"). build_and_sign_replace_after_nonce (line 576) still takes &Document and clones at line 588 before passing it into BatchTransition::new_document_replacement_transition_from_document.
Mirroring the create path's owned-document signature on replace would drop one Document clone per call and keep the "single Document clone end-to-end" invariant symmetric across create and replace.
source: ['claude']
| export interface PrepareDocumentCreateOptions { | ||
| /** | ||
| * The document to create. | ||
| * | ||
| * **Migration note (id ↔ entropy invariant):** `document.id` must match | ||
| * the id derived from `(dataContractId, ownerId, documentTypeName, entropy)` | ||
| * via the v0 document-id derivation. Mismatches are rejected with | ||
| * `InvalidArgument` before any identity-contract nonce is allocated, so | ||
| * failed calls do not advance the local nonce cache. The `Document` | ||
| * constructor derives both together by default; if you set the id or | ||
| * entropy explicitly, keep them consistent. | ||
| */ | ||
| document: Document; |
There was a problem hiding this comment.
💬 Nitpick: PrepareDocument*Options TS types no longer match the plain-object shapes the runtime accepts
PrepareDocumentCreateOptions / PrepareDocumentReplaceOptions advertise document: Document, but extract_prepare_document (lines 1681+) intentionally accepts either a real Document instance or a plain-object { id, ownerId, dataContractId, documentTypeName, properties, … } bag, and the new unit tests in packages/wasm-sdk/tests/unit/prepare-document.spec.ts only exercise the plain-object shape.
Typed TS consumers therefore cannot use the structural plain-object path without an as any cast. Update the generated TypeScript declarations to express Document | PlainDocumentOptions (with PlainDocumentOptions defined alongside) so the declared ABI matches the runtime contract. The same mismatch propagates to the one-shot documentCreate / documentReplace after their migration to the shared extractor.
source: ['codex']
| pub mod unshield; | ||
| pub mod update_price_of_document; | ||
| pub(crate) mod validation; | ||
| pub mod validation; |
There was a problem hiding this comment.
💬 Nitpick: validation module widened from pub(crate) to pub without explicit stability commitment
Changing pub(crate) mod validation; to pub mod validation; makes ensure_valid_state_transition_structure and map_validation_result part of the rs-sdk public API. The module doc-comment justifies the UnsupportedFeatureError filtering behavior (including the "all errors are unsupported → Ok" semantic) as an internal design choice — but exposing this publicly freezes that behavior as a stability commitment to downstream native crates.
If the intent is workspace-only sharing for the wasm-sdk regression test, prefer pub(crate) plus a #[cfg(any(test, feature = "internal"))] re-export, or move the regression test inside rs-sdk. If the public exposure is intentional, mark the module's doc-comment as a stable API surface so the filtering semantics are explicitly contractual.
source: ['claude']
| #[deprecated( | ||
| note = "use build_signed_document_create_transition or build_signed_document_replace_transition for strict intent validation" | ||
| )] | ||
| pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>( | ||
| sdk: &Sdk, | ||
| document: &Document, | ||
| document_type: &DocumentType, | ||
| document_state_transition_entropy: Option<[u8; 32]>, | ||
| identity_public_key: &IdentityPublicKey, | ||
| token_payment_info: Option<TokenPaymentInfo>, | ||
| signer: &S, | ||
| settings: Option<PutSettings>, | ||
| ) -> Result<StateTransition, Error> { | ||
| build_signed_document_create_or_replace_transition_legacy( | ||
| sdk, | ||
| document, | ||
| document_type, | ||
| document_state_transition_entropy, | ||
| identity_public_key, | ||
| token_payment_info, | ||
| signer, | ||
| settings, | ||
| ) | ||
| .await | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Deprecated build_signed_document_create_or_replace_transition has no removal milestone
build_signed_document_create_or_replace_transition is marked #[deprecated(note = "…")] and forwards to _legacy, but it remains fully pub with no since / removal-version note. The deprecation alone won't migrate downstream native callers, so without a planned removal version or #[doc(hidden)] it will accrete forever alongside the strict helpers.
Either pair #[deprecated] with since = "…" and a note pointing at a removal milestone, or move the legacy dispatcher behind #[doc(hidden)] + a sealed pub(crate) wrapper so the public API is just the strict helpers.
source: ['claude']
| fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result<(), Error> { | ||
| if result.is_valid() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| if result.errors.iter().all(is_unsupported_feature_error) { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Mixed `UnsupportedFeatureError` + real-error case. The default | ||
| // `From<SimpleConsensusValidationResult> for Error` conversion keeps | ||
| // the *first* error as a typed `ConsensusError`. Stable-partition the | ||
| // list so real failures come first, ensuring the typed error returned | ||
| // is the most actionable one and not an `UnsupportedFeatureError` | ||
| // placeholder. We deliberately use the existing `From` conversion so | ||
| // the returned `Error` preserves the typed `ConsensusError` variant | ||
| // for downstream pattern-matching, instead of being flattened into a | ||
| // `ProtocolError::Generic` string. | ||
| result.errors.sort_by_key(|e| { | ||
| if is_unsupported_feature_error(e) { | ||
| 1 | ||
| } else { | ||
| 0 | ||
| } | ||
| }); | ||
| Err(Error::from(result)) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: PR description says map_validation_result "filters" UnsupportedFeatureError but it actually sorts
The PR description's compatibility note claims ensure_valid_state_transition_structure filters UnsupportedFeatureError entries out of mixed structure-validation results. The implementation does not filter — it stable-sorts the errors so unsupported entries come last, then converts via the existing From<SimpleConsensusValidationResult> for Error, which surfaces only the first error.
Consumer-visible effect is similar (the typed Error is the first non-unsupported entry), but result.errors still contains every unsupported entry internally. Update the PR description (or the module doc-comment) so downstream callers reading either source aren't misled about iteration / count / errors.len() semantics.
source: ['claude']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR introduces two-phase document state-transition APIs in wasm-sdk plus strict rs-sdk builders with nonce-rollback discipline. Implementation is thorough and well-documented. The most contested finding is the new Document __type/__wbg_ptr fast path: three codex agents flag it as a blocking memory-safety regression at the JS→Wasm boundary, while two claude agents accept it as matching the documented trust-model carve-out (consistent with prior identityKey/signer handling). After verification, the module-level docs explicitly accept this trade-off, so it is surfaced as a high-confidence suggestion rather than blocking. Remaining findings are minor: docs/wording drift, duplicated version dispatch across crates, redundant checks, and small clone-perf nits.
Reviewed commit: cf1cda4
🟡 5 suggestion(s) | 💬 5 nitpick(s)
1 additional finding
💬 nitpick: Revision and id-entropy validations run twice in `DocumentCreateTransitionBuilder`
packages/rs-sdk/src/platform/documents/transitions/create.rs (lines 204-290)
sign calls ensure_revision_for_create and ensure_document_id_matches_entropy before allocating a nonce, then sign_with_nonce repeats both checks (each ensure_document_id_matches_entropy recomputes Document::generate_document_id_v0 from scratch). Each entry point needs to be safe when called directly, but the duplication means a future loosening/tightening of either check must be applied in two sites — not caught by tests since both currently use identical inputs. Extract a single private validate(&self, platform_version) helper called from both entry points so the contract is stated once. Note this duplication does not exist on the replace builder, so the two builders drift relative to each other.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1709-1716: Document fast path dereferences caller-controlled `__wbg_ptr` on a public JS input
The new fast path in `extract_prepare_document` routes any JS value reporting `__type === "Document"` through `DocumentWasm::try_from(&document_js)`, which in `wasm-dpp2::utils::generic_of_js_val` reads `__wbg_ptr` and calls `unsafe { T::ref_from_abi(ptr_u32) }` with no provenance check. Because `__type` and `__wbg_ptr` are ordinary JS properties on the publicly-exported `documentCreate` / `documentReplace` / `prepareDocumentCreate` / `prepareDocumentReplace` entry points, a malicious in-process JS caller can forge `{ __type: 'Document', __wbg_ptr: <chosen u32> }` and force the wasm module to dereference an attacker-chosen pointer. The damage is bounded by the wasm sandbox — the bogus deref cannot escape linear memory — but it can still trap the wasm instance or read/corrupt other wasm-resident objects (e.g. cached signing material in `IdentitySigner`) for the same SDK instance.
This matches the existing carve-outs for `identityKey` and `signer` documented at the top of the file, and the module docstring (lines 99–103) explicitly puts forged-`__wbg_ptr` inputs out of scope of the API's safety guarantees. Unlike `IdentitySigner` (which has no reconstructable public surface) and `IdentityPublicKey` (only ever produced inside the SDK), `Document` legitimately crosses an untrusted boundary from JSON / object literals — and the structural fallback already in this function handles those inputs without ever touching `__wbg_ptr`. The motivation for the trusted branch is preserving typed `platform_value` properties (Identifier/Bytes/U64) on real `Document` class instances, which is a real correctness gain for legitimate callers.
Two options worth weighing:
1. Drop the fast path entirely and rely on the structural extraction for all inputs, accepting any typed-property degradation as the cost of a tighter trust boundary on a publicly-exported API.
2. Keep the fast path but gate it on a brand check that is not a publicly-spoofable JS property (e.g. a WeakSet membership probe maintained by wasm-dpp2, or a private symbol-keyed brand). This preserves the typed-property gain while closing the spoofed-`__type`/`__wbg_ptr` hole.
The `get_class_type` precheck is also informational only — `DocumentWasm::try_from` re-reads `__type` internally and trusts `__wbg_ptr` regardless — so it should not be read as an authorization gate by future maintainers.
- [SUGGESTION] lines 1816-1855: Version-dispatch logic for document-id derivation is duplicated across wasm-sdk and rs-sdk
`ensure_document_id_matches_entropy_fast` independently re-implements the platform-version match on `derive_document_id` that already lives in `rs-sdk` (`put_document.rs::derive_document_id`). Both arms (`0 -> generate_document_id_v0`, other -> error) are mirrored. The wasm side is documented as an early-reject optimization, but any future bump to `derive_document_id = 1` requires synchronized edits in two crates — a classic drift hazard for a consensus-relevant derivation. Expose `derive_document_id` (or a thin `validate_id_matches_entropy`) as `pub` from `rs-sdk` so the wasm fast path calls the same function rather than maintaining a parallel dispatch table.
In `packages/rs-sdk/src/platform/transition/put_settings.rs`:
- [SUGGESTION] lines 58-74: `split_dedicated_fields` silently nulls a prior settings-derived value when re-issued with `None`
When `*_explicit` is false, `split_dedicated_fields` unconditionally writes `self.user_fee_increase` (which may be `None`) into the dedicated field. The sequence `with_settings(PutSettings { user_fee_increase: Some(7), .. })` followed by `with_settings(PutSettings { user_fee_increase: None, .. })` therefore silently nulls out the previously settings-derived value. The doc claim that a second `with_settings` call "replaces the prior settings-derived value" is satisfied in the strict sense (`None` *is* a replacement), but only the Some→Some replacement case is tested. Either gate the overwrite on `Some(_)` or add a test that pins the current None-overwrite semantics so callers understand re-issuing a partial `PutSettings` clears prior settings-derived overrides.
In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 17-69: PR description says `UnsupportedFeatureError` entries are filtered out, but the implementation reorders them
The PR description states `ensure_valid_state_transition_structure` *filters* `UnsupportedFeatureError` entries out of mixed structure-validation results. The actual implementation in `map_validation_result` does not filter — it stable-sorts the error list so non-`UnsupportedFeatureError` entries come first, and the `From<SimpleConsensusValidationResult> for Error` conversion then picks the first error. `UnsupportedFeatureError` entries remain inside the `SimpleConsensusValidationResult` even when a real error is present; only the *first* typed error returned to callers changes. The reorder-not-filter behavior is arguably better (it preserves full diagnostics and only changes which error becomes the typed primary), but the PR description and any downstream consumers reading it may write tests for the wrong invariant. Update the PR wording, or actually drop the unsupported entries.
- [SUGGESTION] lines 43-69: All-`UnsupportedFeatureError` pass-through is identified by error contents, not state-transition kind
`map_validation_result` treats any validation result whose errors are entirely `UnsupportedFeatureError` as a no-op pass. This was previously `pub(crate)`-only; the PR now exposes it as `pub` and routes every prepare/strict-create/replace/delete path through it, broadening the blast radius. The implicit contract is "DPP uses an all-unsupported result as the sentinel for 'structure validation not implemented for this ST kind'." If a future DPP version legitimately emits a single `UnsupportedFeatureError` to report a real-but-unsupported sub-feature on a state transition whose other structural checks happen to pass, the pre-broadcast guard will silently accept the ST and broadcast it. The local nonce-rollback contract assumes structure validation is a real fail-fast gate. Detecting the "validation not implemented" sentinel via the state-transition variant (e.g. `matches!(state_transition, StateTransition::IdentityCreate(_) | ...)`) is more robust than inferring it from error contents. Inline docs explain the current behavior well, so this is non-blocking.
| if document_js.is_object() | ||
| && get_class_type(&document_js) | ||
| .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))? | ||
| == "Document" | ||
| { | ||
| return DocumentWasm::try_from(&document_js) | ||
| .map_err(|err| WasmSdkError::invalid_argument(err.to_string())); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Document fast path dereferences caller-controlled __wbg_ptr on a public JS input
The new fast path in extract_prepare_document routes any JS value reporting __type === "Document" through DocumentWasm::try_from(&document_js), which in wasm-dpp2::utils::generic_of_js_val reads __wbg_ptr and calls unsafe { T::ref_from_abi(ptr_u32) } with no provenance check. Because __type and __wbg_ptr are ordinary JS properties on the publicly-exported documentCreate / documentReplace / prepareDocumentCreate / prepareDocumentReplace entry points, a malicious in-process JS caller can forge { __type: 'Document', __wbg_ptr: <chosen u32> } and force the wasm module to dereference an attacker-chosen pointer. The damage is bounded by the wasm sandbox — the bogus deref cannot escape linear memory — but it can still trap the wasm instance or read/corrupt other wasm-resident objects (e.g. cached signing material in IdentitySigner) for the same SDK instance.
This matches the existing carve-outs for identityKey and signer documented at the top of the file, and the module docstring (lines 99–103) explicitly puts forged-__wbg_ptr inputs out of scope of the API's safety guarantees. Unlike IdentitySigner (which has no reconstructable public surface) and IdentityPublicKey (only ever produced inside the SDK), Document legitimately crosses an untrusted boundary from JSON / object literals — and the structural fallback already in this function handles those inputs without ever touching __wbg_ptr. The motivation for the trusted branch is preserving typed platform_value properties (Identifier/Bytes/U64) on real Document class instances, which is a real correctness gain for legitimate callers.
Two options worth weighing:
- Drop the fast path entirely and rely on the structural extraction for all inputs, accepting any typed-property degradation as the cost of a tighter trust boundary on a publicly-exported API.
- Keep the fast path but gate it on a brand check that is not a publicly-spoofable JS property (e.g. a WeakSet membership probe maintained by wasm-dpp2, or a private symbol-keyed brand). This preserves the typed-property gain while closing the spoofed-
__type/__wbg_ptrhole.
The get_class_type precheck is also informational only — DocumentWasm::try_from re-reads __type internally and trusts __wbg_ptr regardless — so it should not be read as an authorization gate by future maintainers.
source: ['claude', 'codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1709-1716: Document fast path dereferences caller-controlled `__wbg_ptr` on a public JS input
The new fast path in `extract_prepare_document` routes any JS value reporting `__type === "Document"` through `DocumentWasm::try_from(&document_js)`, which in `wasm-dpp2::utils::generic_of_js_val` reads `__wbg_ptr` and calls `unsafe { T::ref_from_abi(ptr_u32) }` with no provenance check. Because `__type` and `__wbg_ptr` are ordinary JS properties on the publicly-exported `documentCreate` / `documentReplace` / `prepareDocumentCreate` / `prepareDocumentReplace` entry points, a malicious in-process JS caller can forge `{ __type: 'Document', __wbg_ptr: <chosen u32> }` and force the wasm module to dereference an attacker-chosen pointer. The damage is bounded by the wasm sandbox — the bogus deref cannot escape linear memory — but it can still trap the wasm instance or read/corrupt other wasm-resident objects (e.g. cached signing material in `IdentitySigner`) for the same SDK instance.
This matches the existing carve-outs for `identityKey` and `signer` documented at the top of the file, and the module docstring (lines 99–103) explicitly puts forged-`__wbg_ptr` inputs out of scope of the API's safety guarantees. Unlike `IdentitySigner` (which has no reconstructable public surface) and `IdentityPublicKey` (only ever produced inside the SDK), `Document` legitimately crosses an untrusted boundary from JSON / object literals — and the structural fallback already in this function handles those inputs without ever touching `__wbg_ptr`. The motivation for the trusted branch is preserving typed `platform_value` properties (Identifier/Bytes/U64) on real `Document` class instances, which is a real correctness gain for legitimate callers.
Two options worth weighing:
1. Drop the fast path entirely and rely on the structural extraction for all inputs, accepting any typed-property degradation as the cost of a tighter trust boundary on a publicly-exported API.
2. Keep the fast path but gate it on a brand check that is not a publicly-spoofable JS property (e.g. a WeakSet membership probe maintained by wasm-dpp2, or a private symbol-keyed brand). This preserves the typed-property gain while closing the spoofed-`__type`/`__wbg_ptr` hole.
The `get_class_type` precheck is also informational only — `DocumentWasm::try_from` re-reads `__type` internally and trusts `__wbg_ptr` regardless — so it should not be read as an authorization gate by future maintainers.
| fn ensure_document_id_matches_entropy_fast( | ||
| document_id: Identifier, | ||
| contract_id: Identifier, | ||
| owner_id: Identifier, | ||
| document_type_name: &str, | ||
| entropy: &[u8; 32], | ||
| platform_version: &dash_sdk::dpp::version::PlatformVersion, | ||
| ) -> Result<(), WasmSdkError> { | ||
| let version = platform_version | ||
| .dpp | ||
| .document_versions | ||
| .document_method_versions | ||
| .derive_document_id; | ||
| let expected = match version { | ||
| 0 => Document::generate_document_id_v0( | ||
| &contract_id, | ||
| &owner_id, | ||
| document_type_name, | ||
| entropy.as_slice(), | ||
| ), | ||
| _ => { | ||
| return Err(WasmSdkError::invalid_argument(format!( | ||
| "unknown derive_document_id version {version}; \ | ||
| the wasm-sdk fast id-vs-entropy check only knows v0. \ | ||
| Upgrade wasm-sdk to a build that supports this platform version." | ||
| ))); | ||
| } | ||
| }; | ||
| if document_id != expected { | ||
| return Err(WasmSdkError::invalid_argument(format!( | ||
| "document.id does not match the platform-version-dispatched \ | ||
| document-id derivation \ | ||
| (dataContractId, ownerId, documentTypeName, entropy); \ | ||
| expected {expected}, got {document_id}. \ | ||
| The Document constructor derives both together by default; if you set the \ | ||
| id or entropy explicitly, keep them consistent." | ||
| ))); | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Version-dispatch logic for document-id derivation is duplicated across wasm-sdk and rs-sdk
ensure_document_id_matches_entropy_fast independently re-implements the platform-version match on derive_document_id that already lives in rs-sdk (put_document.rs::derive_document_id). Both arms (0 -> generate_document_id_v0, other -> error) are mirrored. The wasm side is documented as an early-reject optimization, but any future bump to derive_document_id = 1 requires synchronized edits in two crates — a classic drift hazard for a consensus-relevant derivation. Expose derive_document_id (or a thin validate_id_matches_entropy) as pub from rs-sdk so the wasm fast path calls the same function rather than maintaining a parallel dispatch table.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1816-1855: Version-dispatch logic for document-id derivation is duplicated across wasm-sdk and rs-sdk
`ensure_document_id_matches_entropy_fast` independently re-implements the platform-version match on `derive_document_id` that already lives in `rs-sdk` (`put_document.rs::derive_document_id`). Both arms (`0 -> generate_document_id_v0`, other -> error) are mirrored. The wasm side is documented as an early-reject optimization, but any future bump to `derive_document_id = 1` requires synchronized edits in two crates — a classic drift hazard for a consensus-relevant derivation. Expose `derive_document_id` (or a thin `validate_id_matches_entropy`) as `pub` from `rs-sdk` so the wasm fast path calls the same function rather than maintaining a parallel dispatch table.
| pub fn split_dedicated_fields( | ||
| mut self, | ||
| dedicated_user_fee_increase: &mut Option<UserFeeIncrease>, | ||
| user_fee_increase_explicit: bool, | ||
| dedicated_state_transition_creation_options: &mut Option<StateTransitionCreationOptions>, | ||
| state_transition_creation_options_explicit: bool, | ||
| ) -> Self { | ||
| if !user_fee_increase_explicit { | ||
| *dedicated_user_fee_increase = self.user_fee_increase; | ||
| } | ||
| if !state_transition_creation_options_explicit { | ||
| *dedicated_state_transition_creation_options = self.state_transition_creation_options; | ||
| } | ||
| self.user_fee_increase = None; | ||
| self.state_transition_creation_options = None; | ||
| self | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: split_dedicated_fields silently nulls a prior settings-derived value when re-issued with None
When *_explicit is false, split_dedicated_fields unconditionally writes self.user_fee_increase (which may be None) into the dedicated field. The sequence with_settings(PutSettings { user_fee_increase: Some(7), .. }) followed by with_settings(PutSettings { user_fee_increase: None, .. }) therefore silently nulls out the previously settings-derived value. The doc claim that a second with_settings call "replaces the prior settings-derived value" is satisfied in the strict sense (None is a replacement), but only the Some→Some replacement case is tested. Either gate the overwrite on Some(_) or add a test that pins the current None-overwrite semantics so callers understand re-issuing a partial PutSettings clears prior settings-derived overrides.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_settings.rs`:
- [SUGGESTION] lines 58-74: `split_dedicated_fields` silently nulls a prior settings-derived value when re-issued with `None`
When `*_explicit` is false, `split_dedicated_fields` unconditionally writes `self.user_fee_increase` (which may be `None`) into the dedicated field. The sequence `with_settings(PutSettings { user_fee_increase: Some(7), .. })` followed by `with_settings(PutSettings { user_fee_increase: None, .. })` therefore silently nulls out the previously settings-derived value. The doc claim that a second `with_settings` call "replaces the prior settings-derived value" is satisfied in the strict sense (`None` *is* a replacement), but only the Some→Some replacement case is tested. Either gate the overwrite on `Some(_)` or add a test that pins the current None-overwrite semantics so callers understand re-issuing a partial `PutSettings` clears prior settings-derived overrides.
| /// Convert a structure-validation result into [`Error`], with one special | ||
| /// case for [`UnsupportedFeatureError`]. | ||
| /// | ||
| /// `UnsupportedFeatureError` has *two* meanings in DPP: | ||
| /// | ||
| /// 1. **"Structure validation is not implemented for this state transition | ||
| /// kind"** — e.g. identity-based STs return a result that is *entirely* | ||
| /// `UnsupportedFeatureError` entries. In this case we treat the result | ||
| /// as a no-op pass so the prepare APIs can sign and broadcast these STs | ||
| /// even though their structure check is a stub. | ||
| /// 2. **"A specific feature inside an otherwise-validated ST is not | ||
| /// supported on this platform version"** — in this case the result | ||
| /// mixes `UnsupportedFeatureError` entries with real validation | ||
| /// failures. Here the unsupported entries are *not* placeholders: they | ||
| /// are legitimate rejections that explain why a particular sub-feature | ||
| /// is unavailable, and silently dropping them would discard | ||
| /// user-visible diagnostic information. | ||
| /// | ||
| /// To honor both meanings we only treat the "all errors are unsupported" | ||
| /// case as `Ok`. Once *any* non-unsupported error is present we surface | ||
| /// the result via the existing `From<SimpleConsensusValidationResult> for | ||
| /// Error` conversion — which keeps the first error as a *typed* | ||
| /// `ConsensusError` so callers can pattern-match on it. To avoid the | ||
| /// conversion picking an `UnsupportedFeatureError` placeholder when a | ||
| /// real failure is also present, we first reorder the error list so the | ||
| /// first non-`UnsupportedFeatureError` entry is primary. | ||
| fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result<(), Error> { | ||
| if result.is_valid() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| if result.errors.iter().all(is_unsupported_feature_error) { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Mixed `UnsupportedFeatureError` + real-error case. The default | ||
| // `From<SimpleConsensusValidationResult> for Error` conversion keeps | ||
| // the *first* error as a typed `ConsensusError`. Stable-partition the | ||
| // list so real failures come first, ensuring the typed error returned | ||
| // is the most actionable one and not an `UnsupportedFeatureError` | ||
| // placeholder. We deliberately use the existing `From` conversion so | ||
| // the returned `Error` preserves the typed `ConsensusError` variant | ||
| // for downstream pattern-matching, instead of being flattened into a | ||
| // `ProtocolError::Generic` string. | ||
| result.errors.sort_by_key(|e| { | ||
| if is_unsupported_feature_error(e) { | ||
| 1 | ||
| } else { | ||
| 0 | ||
| } | ||
| }); | ||
| Err(Error::from(result)) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: PR description says UnsupportedFeatureError entries are filtered out, but the implementation reorders them
The PR description states ensure_valid_state_transition_structure filters UnsupportedFeatureError entries out of mixed structure-validation results. The actual implementation in map_validation_result does not filter — it stable-sorts the error list so non-UnsupportedFeatureError entries come first, and the From<SimpleConsensusValidationResult> for Error conversion then picks the first error. UnsupportedFeatureError entries remain inside the SimpleConsensusValidationResult even when a real error is present; only the first typed error returned to callers changes. The reorder-not-filter behavior is arguably better (it preserves full diagnostics and only changes which error becomes the typed primary), but the PR description and any downstream consumers reading it may write tests for the wrong invariant. Update the PR wording, or actually drop the unsupported entries.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 17-69: PR description says `UnsupportedFeatureError` entries are filtered out, but the implementation reorders them
The PR description states `ensure_valid_state_transition_structure` *filters* `UnsupportedFeatureError` entries out of mixed structure-validation results. The actual implementation in `map_validation_result` does not filter — it stable-sorts the error list so non-`UnsupportedFeatureError` entries come first, and the `From<SimpleConsensusValidationResult> for Error` conversion then picks the first error. `UnsupportedFeatureError` entries remain inside the `SimpleConsensusValidationResult` even when a real error is present; only the *first* typed error returned to callers changes. The reorder-not-filter behavior is arguably better (it preserves full diagnostics and only changes which error becomes the typed primary), but the PR description and any downstream consumers reading it may write tests for the wrong invariant. Update the PR wording, or actually drop the unsupported entries.
| fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result<(), Error> { | ||
| if result.is_valid() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| if result.errors.iter().all(is_unsupported_feature_error) { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Mixed `UnsupportedFeatureError` + real-error case. The default | ||
| // `From<SimpleConsensusValidationResult> for Error` conversion keeps | ||
| // the *first* error as a typed `ConsensusError`. Stable-partition the | ||
| // list so real failures come first, ensuring the typed error returned | ||
| // is the most actionable one and not an `UnsupportedFeatureError` | ||
| // placeholder. We deliberately use the existing `From` conversion so | ||
| // the returned `Error` preserves the typed `ConsensusError` variant | ||
| // for downstream pattern-matching, instead of being flattened into a | ||
| // `ProtocolError::Generic` string. | ||
| result.errors.sort_by_key(|e| { | ||
| if is_unsupported_feature_error(e) { | ||
| 1 | ||
| } else { | ||
| 0 | ||
| } | ||
| }); | ||
| Err(Error::from(result)) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: All-UnsupportedFeatureError pass-through is identified by error contents, not state-transition kind
map_validation_result treats any validation result whose errors are entirely UnsupportedFeatureError as a no-op pass. This was previously pub(crate)-only; the PR now exposes it as pub and routes every prepare/strict-create/replace/delete path through it, broadening the blast radius. The implicit contract is "DPP uses an all-unsupported result as the sentinel for 'structure validation not implemented for this ST kind'." If a future DPP version legitimately emits a single UnsupportedFeatureError to report a real-but-unsupported sub-feature on a state transition whose other structural checks happen to pass, the pre-broadcast guard will silently accept the ST and broadcast it. The local nonce-rollback contract assumes structure validation is a real fail-fast gate. Detecting the "validation not implemented" sentinel via the state-transition variant (e.g. matches!(state_transition, StateTransition::IdentityCreate(_) | ...)) is more robust than inferring it from error contents. Inline docs explain the current behavior well, so this is non-blocking.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 43-69: All-`UnsupportedFeatureError` pass-through is identified by error contents, not state-transition kind
`map_validation_result` treats any validation result whose errors are entirely `UnsupportedFeatureError` as a no-op pass. This was previously `pub(crate)`-only; the PR now exposes it as `pub` and routes every prepare/strict-create/replace/delete path through it, broadening the blast radius. The implicit contract is "DPP uses an all-unsupported result as the sentinel for 'structure validation not implemented for this ST kind'." If a future DPP version legitimately emits a single `UnsupportedFeatureError` to report a real-but-unsupported sub-feature on a state transition whose other structural checks happen to pass, the pre-broadcast guard will silently accept the ST and broadcast it. The local nonce-rollback contract assumes structure validation is a real fail-fast gate. Detecting the "validation not implemented" sentinel via the state-transition variant (e.g. `matches!(state_transition, StateTransition::IdentityCreate(_) | ...)`) is more robust than inferring it from error contents. Inline docs explain the current behavior well, so this is non-blocking.
| // Resolve the owned document type from the contract. | ||
| let document_type = data_contract | ||
| .document_type_cloned_for_name(&document_type_name) | ||
| .map_err(|e| Error::Protocol(e.into()))?; | ||
|
|
||
| // Route through the strict create helper so the one-shot | ||
| // `document_create` builder API gets the same fail-fast revision | ||
| // and id-matches-entropy validation as the wasm-sdk | ||
| // `prepareDocumentCreate` path. Pre-broadcast errors roll back the | ||
| // allocated identity-contract nonce inside the helper. | ||
| let state_transition = build_signed_document_create_transition( | ||
| self, | ||
| &document, | ||
| &document_type, | ||
| document_state_transition_entropy, | ||
| signing_key, | ||
| token_payment_info, | ||
| signer, | ||
| Some(effective_settings), | ||
| ) | ||
| .await?; |
There was a problem hiding this comment.
💬 Nitpick: Sdk::document_create performs an extra Document clone on the one-shot path
Sdk::document_create destructures the builder so it owns document outright, but then passes it to build_signed_document_create_transition(self, &document, ...) by reference. That helper unconditionally clones the document on line 425 before forwarding to the owned variant build_signed_document_create_transition_owned. Since document_create already owns the value, it could call the _owned variant directly and achieve the "single Document clone end-to-end" property the module docstring claims. Exposing the _owned helper as pub(crate) would let documents/transitions/create.rs skip the extra clone. Replace path is bottlenecked on &Document end-to-end and unaffected.
source: ['claude']
| fn extract_optional_identifier_property( | ||
| container: &JsValue, | ||
| property_name: &str, | ||
| ) -> Result<Option<Identifier>, WasmSdkError> { | ||
| let value = Reflect::get(container, &JsValue::from_str(property_name)).map_err(|err| { | ||
| WasmSdkError::invalid_argument(format!( | ||
| "failed to read '{}' from options: {:?}", | ||
| property_name, err | ||
| )) | ||
| })?; | ||
|
|
||
| if value.is_undefined() || value.is_null() { | ||
| return Ok(None); | ||
| } | ||
|
|
||
| extract_identifier_property(container, property_name).map(Some) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: extract_optional_identifier_property reads the JS property twice
The helper first calls Reflect::get(container, property_name) to check for undefined/null, and on success delegates to extract_identifier_property(container, property_name), which performs the exact same Reflect::get again. For a class instance whose propertyName is implemented as a JS getter, the getter fires twice: any side effects run twice, and a TOCTOU window opens where the second read could return null after the first non-null read — in which case the function would surface the misleading '<x>' is required error from extract_identifier_property instead of Ok(None). Forward the already-fetched JsValue into a shared inner helper to avoid both issues.
source: ['claude']
| let document: Document = document_wasm.clone().into(); | ||
|
|
||
| ensure_document_replace_revision(document.revision(), "prepareDocumentCreate")?; | ||
|
|
||
| // Get metadata from document |
There was a problem hiding this comment.
💬 Nitpick: Plain-JS-array identifier path silently coerces non-byte elements
When the value is a plain JS Array, js_sys::Uint8Array::from(value.clone()) invokes JS Uint8Array.from(...), which coerces every element via ToNumber then ToUint8 (mod-256 wrap, NaN/undefined -> 0). The result is then handed to identifier_from_bytes, which validates only the length (32). A caller mistake like passing [1, 'two', 3.7, 300, ...] yields a structurally valid Identifier with silently mutated bytes instead of being rejected. Either validate each element is an integer in 0..=255 before coercion, or document that non-byte array elements are silently coerced.
source: ['claude']
| // Delegate the nonce-allocate / sign / structure-validate / rollback | ||
| // sequence to rs-sdk's shared helper. Any pre-broadcast failure | ||
| // inside the helper (sign or local structure validation) rolls the | ||
| // bumped identity-contract nonce back internally, so the local | ||
| // nonce cache cannot advance past a nonce the network never | ||
| // observed. | ||
| let state_transition = build_signed_document_delete_transition( | ||
| &wrapper.sdk, | ||
| &builder, | ||
| identity_public_key, | ||
| signer, | ||
| ) | ||
| .await | ||
| .map_err(|e| { | ||
| FFIError::InternalError(format!("Failed to create delete transition: {}", e)) | ||
| })?; |
There was a problem hiding this comment.
💬 Nitpick: FFI delete path can unwind Rust panics across the extern "C" boundary
dash_sdk_document_delete is pub unsafe extern "C" and awaits build_signed_document_delete_transition(...). The helper performs nonce allocation, signing, structure validation and rollback; any panic inside it (e.g. an unwrap deep in dpp serialization) would unwind across the extern "C" boundary, which is UB on stable Rust. The rest of this FFI module already shares this property, so this PR isn't introducing a new class of risk — it expands the surface that runs inside it. Confirm the crate is compiled with panic = "abort", or wrap the awaited future in AssertUnwindSafe(...).catch_unwind() at the FFI entry.
source: ['claude']
cf1cda4 to
a523a1e
Compare
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
One blocking FFI regression: dash_sdk_document_put_to_platform uses unwrap_or(0) for the create/replace branch decision while dash_sdk_document_create produces revision = None for callers that omit revision — after this PR the strict replace-revision guard rejects those flows, and the wait-variant of the same FFI uses unwrap_or(1) so the two paths now disagree. Otherwise the prepare API design, nonce-rollback semantics, validation-reorder, and structural extraction are sound. Several lower-severity follow-ups: the new typed Error::InvalidArgument is squashed to InternalError by the FFI map_err pattern, the create/replace revision rules are duplicated between wasm-sdk and rs-sdk, and the PutDocument::put_to_platform create path is semver-significant. Dropped 3 findings as false positives.
Reviewed commit: a523a1e
🔴 1 blocking | 🟡 3 suggestion(s) | 💬 3 nitpick(s)
1 additional finding
🔴 blocking: `dash_sdk_document_put_to_platform` misroutes `revision = None` creates into the now-strict replace path
packages/rs-sdk-ffi/src/document/put.rs (line 116)
dash_sdk_document_create maps params.revision == 0 to revision: None (create.rs:336-340). On this no-wait FFI entry, document.revision().unwrap_or(0) == 1 evaluates to false for that input and falls through to DocumentReplaceTransitionBuilder. After this PR, DocumentReplaceTransitionBuilder::sign runs ensure_revision_for_replace(self.document.revision()) (replace.rs:201, also at 253), which rejects None with Error::InvalidArgument("document must have a revision set for replace; ...").
The wait-variant dash_sdk_document_put_to_platform_and_wait uses unwrap_or(1) == 1 at put.rs:288 and still treats this case as create, so the two FFI APIs now disagree on identical inputs. Pre-existing FFI/Swift callers that built a document via dash_sdk_document_create with the revision left unset and then handed it to the no-wait put will see this fail only on the serialized path.
This is a behavioral regression caused by the PR's tightening. Align the no-wait branch with the wait-variant.
💡 Suggested change
let state_transition = if document.revision().unwrap_or(1) == 1 {
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk-ffi/src/document/put.rs`:
- [BLOCKING] line 116: `dash_sdk_document_put_to_platform` misroutes `revision = None` creates into the now-strict replace path
`dash_sdk_document_create` maps `params.revision == 0` to `revision: None` (create.rs:336-340). On this no-wait FFI entry, `document.revision().unwrap_or(0) == 1` evaluates to `false` for that input and falls through to `DocumentReplaceTransitionBuilder`. After this PR, `DocumentReplaceTransitionBuilder::sign` runs `ensure_revision_for_replace(self.document.revision())` (replace.rs:201, also at 253), which rejects `None` with `Error::InvalidArgument("document must have a revision set for replace; ...")`.
The wait-variant `dash_sdk_document_put_to_platform_and_wait` uses `unwrap_or(1) == 1` at put.rs:288 and still treats this case as create, so the two FFI APIs now disagree on identical inputs. Pre-existing FFI/Swift callers that built a document via `dash_sdk_document_create` with the revision left unset and then handed it to the no-wait put will see this fail only on the serialized path.
This is a behavioral regression caused by the PR's tightening. Align the no-wait branch with the wait-variant.
In `packages/rs-sdk-ffi/src/document/delete.rs`:
- [SUGGESTION] lines 170-173: Typed `Error::InvalidArgument` dispatch is dead through every document FFI path
This PR adds `Error::InvalidArgument` to `rs-sdk` (error.rs:99-103) and a matching typed branch in `rs-sdk-ffi/src/error.rs:104-111` that maps it to `DashSDKErrorCode::InvalidParameter`. That branch only fires for `FFIError::SDKError(_)`. Every document FFI callsite — `delete.rs:171-173`, `put.rs:182-184` / `:317-319` / `:354-356`, `create.rs`, `replace.rs:152-154` / `:357-360`, `transfer.rs`, `price.rs`, `purchase.rs` — wraps the SDK error as `FFIError::InternalError(format!("...: {}", e))`, which routes through `FFIError::InternalError(_) => (DashSDKErrorCode::InternalError, ...)` (error.rs:161) and discards the typed information.
Net effect for iOS/FFI consumers: a `prepare`-style flow that fails `ensure_revision_for_create`/`ensure_revision_for_replace`/`ensure_document_id_matches_entropy` surfaces with `DashSDKErrorCode::InternalError` instead of `InvalidParameter`. Code clients that branch on error code (the documented FFI contract) treat caller-input bugs as internal SDK bugs.
Easiest fix: pass the SDK error through `FFIError::SDKError(e)` (or `FFIError::from(e)` via the existing `#[from]` impl) so the typed dispatch fires. If the descriptive prefix matters, special-case `Error::InvalidArgument` before wrapping the rest as `InternalError`. As-is, the typed dispatch added in this PR has no observable effect through any in-tree FFI document path.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1841-1887: Revision intent rules duplicated across wasm-sdk and rs-sdk
`ensure_document_create_revision` / `ensure_document_replace_revision` re-encode the acceptance rules already implemented in rs-sdk as `ensure_revision_for_create` / `ensure_revision_for_replace` (put_document.rs:96-109). The wasm copies exist to fail fast before contract fetch, but they become a second source of truth for API semantics, with their own error messages and tests. Any future change to the create/replace boundary now has to be kept in sync across two crates. Given that this PR is explicitly tightening the create-vs-replace contract, the duplication raises the risk of JS-facing behavior drifting from the native builders. Consider re-exporting the rs-sdk helpers (or a thin shared validator) so both layers share one source of truth.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 724-809: `PutDocument::put_to_platform` create path is now semver-significant
The legacy `PutDocument::put_to_platform` create path now routes through `build_signed_document_create_transition` / `_owned` and enforces id-matches-entropy when `document_state_transition_entropy = Some(entropy)`, plus rejects `Some(0)` revisions via `ensure_revision_nonzero`. The PR description verifies in-tree consumers are unaffected, but `PutDocument` is a public trait, and out-of-tree Rust SDK consumers may have built `Document` objects with hand-picked ids alongside `Some(entropy)` or relied on `Some(0)` revision being silently accepted. Those callers will now get `Error::InvalidArgument` before any nonce allocation. Worth flagging in CHANGELOG / release notes as a semver-significant tightening of an existing public API.
| .await | ||
| .map_err(|e| { | ||
| FFIError::InternalError(format!("Failed to create delete transition: {}", e)) | ||
| })?; |
There was a problem hiding this comment.
🟡 Suggestion: Typed Error::InvalidArgument dispatch is dead through every document FFI path
This PR adds Error::InvalidArgument to rs-sdk (error.rs:99-103) and a matching typed branch in rs-sdk-ffi/src/error.rs:104-111 that maps it to DashSDKErrorCode::InvalidParameter. That branch only fires for FFIError::SDKError(_). Every document FFI callsite — delete.rs:171-173, put.rs:182-184 / :317-319 / :354-356, create.rs, replace.rs:152-154 / :357-360, transfer.rs, price.rs, purchase.rs — wraps the SDK error as FFIError::InternalError(format!("...: {}", e)), which routes through FFIError::InternalError(_) => (DashSDKErrorCode::InternalError, ...) (error.rs:161) and discards the typed information.
Net effect for iOS/FFI consumers: a prepare-style flow that fails ensure_revision_for_create/ensure_revision_for_replace/ensure_document_id_matches_entropy surfaces with DashSDKErrorCode::InternalError instead of InvalidParameter. Code clients that branch on error code (the documented FFI contract) treat caller-input bugs as internal SDK bugs.
Easiest fix: pass the SDK error through FFIError::SDKError(e) (or FFIError::from(e) via the existing #[from] impl) so the typed dispatch fires. If the descriptive prefix matters, special-case Error::InvalidArgument before wrapping the rest as InternalError. As-is, the typed dispatch added in this PR has no observable effect through any in-tree FFI document path.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk-ffi/src/document/delete.rs`:
- [SUGGESTION] lines 170-173: Typed `Error::InvalidArgument` dispatch is dead through every document FFI path
This PR adds `Error::InvalidArgument` to `rs-sdk` (error.rs:99-103) and a matching typed branch in `rs-sdk-ffi/src/error.rs:104-111` that maps it to `DashSDKErrorCode::InvalidParameter`. That branch only fires for `FFIError::SDKError(_)`. Every document FFI callsite — `delete.rs:171-173`, `put.rs:182-184` / `:317-319` / `:354-356`, `create.rs`, `replace.rs:152-154` / `:357-360`, `transfer.rs`, `price.rs`, `purchase.rs` — wraps the SDK error as `FFIError::InternalError(format!("...: {}", e))`, which routes through `FFIError::InternalError(_) => (DashSDKErrorCode::InternalError, ...)` (error.rs:161) and discards the typed information.
Net effect for iOS/FFI consumers: a `prepare`-style flow that fails `ensure_revision_for_create`/`ensure_revision_for_replace`/`ensure_document_id_matches_entropy` surfaces with `DashSDKErrorCode::InternalError` instead of `InvalidParameter`. Code clients that branch on error code (the documented FFI contract) treat caller-input bugs as internal SDK bugs.
Easiest fix: pass the SDK error through `FFIError::SDKError(e)` (or `FFIError::from(e)` via the existing `#[from]` impl) so the typed dispatch fires. If the descriptive prefix matters, special-case `Error::InvalidArgument` before wrapping the rest as `InternalError`. As-is, the typed dispatch added in this PR has no observable effect through any in-tree FFI document path.
| fn ensure_document_create_revision( | ||
| revision: Option<u64>, | ||
| replace_api_name: &str, | ||
| ) -> Result<(), WasmSdkError> { | ||
| match revision { | ||
| None => Ok(()), | ||
| Some(rev) if rev == INITIAL_REVISION => Ok(()), | ||
| // `Some(0)` is invalid for *both* create and replace, so do not | ||
| // point users at the sibling API — they would just see the same | ||
| // rejection from `ensure_document_replace_revision`. Emit a | ||
| // dedicated message that makes the always-invalid value explicit. | ||
| Some(0) => Err(WasmSdkError::invalid_argument(format!( | ||
| "Document revision is 0 but revision 0 is invalid for both create and replace. \ | ||
| Use unset or {} (INITIAL_REVISION) for create, or > {} for replace.", | ||
| INITIAL_REVISION, INITIAL_REVISION, | ||
| ))), | ||
| Some(rev) => Err(WasmSdkError::invalid_argument(format!( | ||
| "Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.", | ||
| rev, INITIAL_REVISION, replace_api_name, | ||
| ))), | ||
| } | ||
| } | ||
|
|
||
| fn ensure_document_replace_revision( | ||
| revision: Option<u64>, | ||
| create_api_name: &str, | ||
| ) -> Result<(), WasmSdkError> { | ||
| match revision { | ||
| Some(rev) if rev > INITIAL_REVISION => Ok(()), | ||
| // `Some(0)` is invalid for *both* create and replace, so do not | ||
| // point users at the sibling API — they would just see the same | ||
| // rejection from `ensure_document_create_revision`. Emit a | ||
| // dedicated message that makes the always-invalid value explicit. | ||
| Some(0) => Err(WasmSdkError::invalid_argument(format!( | ||
| "Document revision is 0 but revision 0 is invalid for both create and replace. \ | ||
| Use unset or {} (INITIAL_REVISION) for create, or > {} for replace.", | ||
| INITIAL_REVISION, INITIAL_REVISION, | ||
| ))), | ||
| Some(rev) => Err(WasmSdkError::invalid_argument(format!( | ||
| "Document revision is {} but replace requires revision > {}. Use {} for new documents.", | ||
| rev, INITIAL_REVISION, create_api_name, | ||
| ))), | ||
| None => Err(WasmSdkError::invalid_argument(format!( | ||
| "Document must have a revision set for replace. Use {} for new documents.", | ||
| create_api_name, | ||
| ))), | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Revision intent rules duplicated across wasm-sdk and rs-sdk
ensure_document_create_revision / ensure_document_replace_revision re-encode the acceptance rules already implemented in rs-sdk as ensure_revision_for_create / ensure_revision_for_replace (put_document.rs:96-109). The wasm copies exist to fail fast before contract fetch, but they become a second source of truth for API semantics, with their own error messages and tests. Any future change to the create/replace boundary now has to be kept in sync across two crates. Given that this PR is explicitly tightening the create-vs-replace contract, the duplication raises the risk of JS-facing behavior drifting from the native builders. Consider re-exporting the rs-sdk helpers (or a thin shared validator) so both layers share one source of truth.
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1841-1887: Revision intent rules duplicated across wasm-sdk and rs-sdk
`ensure_document_create_revision` / `ensure_document_replace_revision` re-encode the acceptance rules already implemented in rs-sdk as `ensure_revision_for_create` / `ensure_revision_for_replace` (put_document.rs:96-109). The wasm copies exist to fail fast before contract fetch, but they become a second source of truth for API semantics, with their own error messages and tests. Any future change to the create/replace boundary now has to be kept in sync across two crates. Given that this PR is explicitly tightening the create-vs-replace contract, the duplication raises the risk of JS-facing behavior drifting from the native builders. Consider re-exporting the rs-sdk helpers (or a thin shared validator) so both layers share one source of truth.
| @@ -58,64 +731,80 @@ impl<S: Signer<IdentityPublicKey>> PutDocument<S> for Document { | |||
| signer: &S, | |||
| settings: Option<PutSettings>, | |||
| ) -> Result<StateTransition, Error> { | |||
| let new_identity_contract_nonce = sdk | |||
| .get_identity_contract_nonce( | |||
| self.owner_id(), | |||
| document_type.data_contract_id(), | |||
| true, | |||
| settings, | |||
| ) | |||
| .await?; | |||
|
|
|||
| let settings = settings.unwrap_or_default(); | |||
| let transition = if self.revision().is_some() | |||
| && self.revision().unwrap() != INITIAL_REVISION | |||
| { | |||
| BatchTransition::new_document_replacement_transition_from_document( | |||
| self.clone(), | |||
| document_type.as_ref(), | |||
| &identity_public_key, | |||
| new_identity_contract_nonce, | |||
| settings.user_fee_increase.unwrap_or_default(), | |||
| token_payment_info, | |||
| signer, | |||
| sdk.version(), | |||
| settings.state_transition_creation_options, | |||
| ) | |||
| .await? | |||
| // Route through the strict create/replace helpers so callers get the | |||
| // same fail-fast revision-vs-intent guarantees as the wasm-sdk | |||
| // `prepareDocumentCreate` / `prepareDocumentReplace` paths. The | |||
| // dispatch is driven by the document revision: unset or | |||
| // `INITIAL_REVISION` selects create; revisions strictly greater than | |||
| // `INITIAL_REVISION` select replace. | |||
| // | |||
| // Reject `Some(0)` up front with the dispatch-aware | |||
| // `ensure_revision_nonzero` message rather than letting it fall into | |||
| // the replace branch — the replace-helper message says "use the | |||
| // create path", which would be misleading for `put_to_platform` | |||
| // callers (they aren't picking a branch themselves). | |||
| ensure_revision_nonzero(self.revision())?; | |||
| let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) { | |||
| // Create path. Avoid the outer pre-resolve clone when the | |||
| // caller already supplied entropy: pass `self` straight to the | |||
| // strict create helper, which clones once internally for | |||
| // `BatchTransition::new_document_creation_transition_from_document`. | |||
| // | |||
| // For the legacy `None` entropy fallback we resolve once here | |||
| // (generate entropy + rewrite document id) and hand the owned | |||
| // document to `build_signed_document_create_transition_owned`, | |||
| // so the create path performs a single Document clone end-to-end. | |||
| // The strict id-matches-entropy check runs before any nonce | |||
| // allocation in both branches. | |||
| match document_state_transition_entropy { | |||
| Some(entropy) => { | |||
| build_signed_document_create_transition( | |||
| sdk, | |||
| self, | |||
| &document_type, | |||
| entropy, | |||
| &identity_public_key, | |||
| token_payment_info, | |||
| signer, | |||
| settings, | |||
| ) | |||
| .await? | |||
| } | |||
| None => { | |||
| let (resolved_document, resolved_entropy) = | |||
| resolve_document_create_entropy(self, &document_type, None, sdk.version())?; | |||
| build_signed_document_create_transition_owned( | |||
| sdk, | |||
| resolved_document, | |||
| &document_type, | |||
| resolved_entropy, | |||
| &identity_public_key, | |||
| token_payment_info, | |||
| signer, | |||
| settings, | |||
| ) | |||
| .await? | |||
| } | |||
| } | |||
| } else { | |||
| let (document, document_state_transition_entropy) = document_state_transition_entropy | |||
| .map(|entropy| (self.clone(), entropy)) | |||
| .unwrap_or_else(|| { | |||
| let mut rng = StdRng::from_entropy(); | |||
| let mut document = self.clone(); | |||
| let entropy = rng.gen::<[u8; 32]>(); | |||
| document.set_id(Document::generate_document_id_v0( | |||
| &document_type.data_contract_id(), | |||
| &document.owner_id(), | |||
| document_type.name(), | |||
| entropy.as_slice(), | |||
| )); | |||
| (document, entropy) | |||
| }); | |||
| BatchTransition::new_document_creation_transition_from_document( | |||
| document, | |||
| document_type.as_ref(), | |||
| document_state_transition_entropy, | |||
| // Replace path: entropy is unused; the strict helper enforces | |||
| // `revision > INITIAL_REVISION`. | |||
| build_signed_document_replace_transition( | |||
| sdk, | |||
| self, | |||
| &document_type, | |||
| &identity_public_key, | |||
| new_identity_contract_nonce, | |||
| settings.user_fee_increase.unwrap_or_default(), | |||
| token_payment_info, | |||
| signer, | |||
| sdk.version(), | |||
| settings.state_transition_creation_options, | |||
| settings, | |||
| ) | |||
| .await? | |||
| }; | |||
| ensure_valid_state_transition_structure(&transition, sdk.version())?; | |||
|
|
|||
| // response is empty for a broadcast, result comes from the stream wait for state transition result | |||
| transition.broadcast(sdk, Some(settings)).await?; | |||
| transition | |||
| .broadcast(sdk, Some(settings.unwrap_or_default())) | |||
| .await?; | |||
| Ok(transition) | |||
| } | |||
There was a problem hiding this comment.
🟡 Suggestion: PutDocument::put_to_platform create path is now semver-significant
The legacy PutDocument::put_to_platform create path now routes through build_signed_document_create_transition / _owned and enforces id-matches-entropy when document_state_transition_entropy = Some(entropy), plus rejects Some(0) revisions via ensure_revision_nonzero. The PR description verifies in-tree consumers are unaffected, but PutDocument is a public trait, and out-of-tree Rust SDK consumers may have built Document objects with hand-picked ids alongside Some(entropy) or relied on Some(0) revision being silently accepted. Those callers will now get Error::InvalidArgument before any nonce allocation. Worth flagging in CHANGELOG / release notes as a semver-significant tightening of an existing public API.
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 724-809: `PutDocument::put_to_platform` create path is now semver-significant
The legacy `PutDocument::put_to_platform` create path now routes through `build_signed_document_create_transition` / `_owned` and enforces id-matches-entropy when `document_state_transition_entropy = Some(entropy)`, plus rejects `Some(0)` revisions via `ensure_revision_nonzero`. The PR description verifies in-tree consumers are unaffected, but `PutDocument` is a public trait, and out-of-tree Rust SDK consumers may have built `Document` objects with hand-picked ids alongside `Some(entropy)` or relied on `Some(0)` revision being silently accepted. Those callers will now get `Error::InvalidArgument` before any nonce allocation. Worth flagging in CHANGELOG / release notes as a semver-significant tightening of an existing public API.
| signer: &impl Signer<IdentityPublicKey>, | ||
| platform_version: &PlatformVersion, | ||
| ) -> Result<StateTransition, Error> { | ||
| { | ||
| let document_type = self | ||
| .data_contract | ||
| .document_type_for_name(&self.document_type_name) | ||
| .map_err(|e| Error::Protocol(e.into()))?; | ||
| ensure_revision_for_create(self.document.revision())?; | ||
| ensure_document_id_matches_entropy( | ||
| &self.document, | ||
| document_type, | ||
| &self.document_state_transition_entropy, | ||
| platform_version, | ||
| )?; | ||
| } | ||
|
|
||
| let owner_id = self.document.owner_id(); | ||
| let contract_id = self.data_contract.id(); | ||
| let identity_contract_nonce = sdk | ||
| .get_identity_contract_nonce( | ||
| self.document.owner_id(), | ||
| self.data_contract.id(), | ||
| true, | ||
| self.settings, | ||
| ) | ||
| .get_identity_contract_nonce(owner_id, contract_id, true, self.settings) | ||
| .await?; | ||
|
|
||
| match self | ||
| .sign_with_nonce( | ||
| identity_contract_nonce, | ||
| identity_public_key, | ||
| signer, | ||
| platform_version, | ||
| ) | ||
| .await | ||
| { | ||
| Ok(transition) => Ok(transition), | ||
| Err(err) => { | ||
| sdk.rollback_identity_contract_nonce( | ||
| owner_id, | ||
| contract_id, | ||
| identity_contract_nonce, | ||
| ) | ||
| .await; | ||
| Err(err) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Signs the document create transition using a pre-allocated | ||
| /// identity-contract nonce. |
There was a problem hiding this comment.
💬 Nitpick: Pre-alloc validation in sign() is duplicated by sign_with_nonce
DocumentCreateTransitionBuilder::sign fetches document_type_for_name, runs ensure_revision_for_create, and runs ensure_document_id_matches_entropy before allocating a nonce. It then delegates to sign_with_nonce (create.rs:281-289), which repeats all three. The duplication is defensive (direct callers of sign_with_nonce still get the guarantee), but every sign(...) call now does the document-type lookup and the two checks twice. Same shape exists in replace.rs::sign. Consider documenting sign_with_nonce as the authoritative validator and either dropping the pre-alloc copy in sign or factoring shared validation into a helper that runs once. Not a correctness issue.
source: ['claude']
| let properties = try_from_options_with(&document_js, "properties", |v| { | ||
| let pv = wasm_dpp2::serialization::js_value_to_platform_value(v)?; | ||
| pv.into_btree_string_map() | ||
| .map_err(|err| wasm_dpp2::error::WasmDppError::invalid_argument(err.to_string())) | ||
| })?; |
There was a problem hiding this comment.
💬 Nitpick: extract_prepare_document retypes 32-byte byteArray properties to Value::Identifier
extract_prepare_document rebuilds Document.properties via wasm_dpp2::serialization::js_value_to_platform_value, which unconditionally converts any 32-byte Uint8Array to Value::Identifier rather than Value::Bytes. This now affects every code path that flows through extract_prepare_document, including the one-shot documentCreate / documentReplace / documentTransfer / documentSetPrice / documentPurchase paths that previously used DocumentWasm::try_from.
In practice this looks safe today: Value::into_binary_bytes accepts Value::Identifier and returns the raw bytes, so the wire output for byteArray properties is unchanged. The concern is type-sensitive consumers (schema validators distinguishing Bytes vs Identifier, equality checks, or future code that branches on the variant). Worth a round-trip regression test: build a document with a 32-byte byteArray property, run it through prepareDocument*, and assert the resulting StateTransition matches the legacy path byte-for-byte.
source: ['claude']
| fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result<(), Error> { | ||
| if result.is_valid() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Pass-through only when *every* error is the DPP sentinel. A | ||
| // non-sentinel `UnsupportedFeatureError` (case 2 above) is a real | ||
| // rejection and must surface as an `Err`. | ||
| if result.errors.iter().all(is_structure_validation_sentinel) { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Mixed real-error / `UnsupportedFeatureError` case. The default | ||
| // `From<SimpleConsensusValidationResult> for Error` conversion keeps | ||
| // the *first* error as a typed `ConsensusError`. Stable-sort so | ||
| // non-`UnsupportedFeatureError` failures come first, ensuring the | ||
| // typed error returned is the most actionable one and not an | ||
| // `UnsupportedFeatureError` entry. We deliberately use the existing | ||
| // `From` conversion so the returned `Error` preserves the typed | ||
| // `ConsensusError` variant for downstream pattern-matching, instead | ||
| // of being flattened into a `ProtocolError::Generic` string. Note | ||
| // this is a **reorder**, not a filter: every original error | ||
| // (sentinel and non-sentinel `UnsupportedFeatureError` alike) | ||
| // remains in the result. | ||
| result.errors.sort_by_key(|e| { | ||
| if is_any_unsupported_feature_error(e) { | ||
| 1 | ||
| } else { | ||
| 0 | ||
| } | ||
| }); | ||
| Err(Error::from(result)) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: map_validation_result reorders errors — preserved order is no longer source-order
map_validation_result stable-sorts result.errors so non-UnsupportedFeatureError entries come first, then constructs Error::from(result). The From<SimpleConsensusValidationResult> for Error conversion picks the typed primary ConsensusError from position 0, so this correctly surfaces the actionable failure. The comment block (lines 89-115) and the mixed_errors_reorder_real_failure_before_unsupported test pin the behavior.
Downstream consumers that iterate the resulting SimpleConsensusValidationResult.errors for structured diagnostics will now see UnsupportedFeatureError entries after real failures even when DPP originally reported them first. Fine for the typed-error-extraction target; worth noting for future readers who route validation results to error-rendering code paths beyond the typed Err return.
source: ['claude']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
I verified the flagged areas against commit 0daa6c5992fc20dc75fe964b93bdb2f33460d98f. The WASM document-transition refactor does not contain the claimed blocking data-corruption bug, but it does leave one real API robustness issue in the rs-sdk builders and one real regression-coverage gap around the new structural FFI hardening.
Reviewed commit: 0daa6c5
🟡 2 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 27-48: Public builder fields bypass the new `with_settings()` invariant tracking
`DocumentCreateTransitionBuilder` now relies on the private `user_fee_increase_explicit` and `state_transition_creation_options_explicit` flags to make `with_settings()` order-independent, but the related semantic fields remain public. Callers can still mutate `settings`, `user_fee_increase`, or `state_transition_creation_options` directly after `new()`, which bypasses the setters that keep those hidden flags in sync. Once that happens, `with_settings()` can silently overwrite a manually assigned value because it has no way to know the field was set explicitly. The same pattern exists in the mirrored replace/delete builders, so the current public API no longer enforces the behavior its new documentation promises.
In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [SUGGESTION] lines 67-400: The new structural FFI hardening is not pinned by adversarial regression tests
The new extraction helpers in `packages/wasm-sdk/src/state_transitions/document.rs` explicitly avoid `DocumentWasm::try_from`, `TokenPaymentInfoWasm::try_from`, and similar pointer-trusting conversions on public JS input. The unit suite covers plain-object success/failure paths, but it never passes forged inputs that spoof `__type` and `__wbg_ptr` for `document`, delete identifier fields, or `tokenPaymentInfo`. Without those adversarial tests, a future refactor could accidentally reintroduce a `__wbg_ptr` dereference on untrusted input while keeping the current test suite green.
| pub settings: Option<PutSettings>, | ||
| pub user_fee_increase: Option<UserFeeIncrease>, | ||
| pub state_transition_creation_options: Option<StateTransitionCreationOptions>, | ||
| /// Tracks whether [`Self::user_fee_increase`] was last set by an | ||
| /// explicit [`Self::with_user_fee_increase`] call (as opposed to being | ||
| /// extracted from a [`Self::with_settings`] call). Used by | ||
| /// [`Self::with_settings`] to honor the "explicit setter wins | ||
| /// regardless of order" contract while still letting a second | ||
| /// `with_settings` call overwrite a prior settings-derived value. | ||
| user_fee_increase_explicit: bool, | ||
| /// Tracks whether [`Self::state_transition_creation_options`] was last | ||
| /// set by an explicit | ||
| /// [`Self::with_state_transition_creation_options`] call; mirrors | ||
| /// [`Self::user_fee_increase_explicit`] for the second dedicated field. | ||
| state_transition_creation_options_explicit: bool, |
There was a problem hiding this comment.
🟡 Suggestion: Public builder fields bypass the new with_settings() invariant tracking
DocumentCreateTransitionBuilder now relies on the private user_fee_increase_explicit and state_transition_creation_options_explicit flags to make with_settings() order-independent, but the related semantic fields remain public. Callers can still mutate settings, user_fee_increase, or state_transition_creation_options directly after new(), which bypasses the setters that keep those hidden flags in sync. Once that happens, with_settings() can silently overwrite a manually assigned value because it has no way to know the field was set explicitly. The same pattern exists in the mirrored replace/delete builders, so the current public API no longer enforces the behavior its new documentation promises.
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 27-48: Public builder fields bypass the new `with_settings()` invariant tracking
`DocumentCreateTransitionBuilder` now relies on the private `user_fee_increase_explicit` and `state_transition_creation_options_explicit` flags to make `with_settings()` order-independent, but the related semantic fields remain public. Callers can still mutate `settings`, `user_fee_increase`, or `state_transition_creation_options` directly after `new()`, which bypasses the setters that keep those hidden flags in sync. Once that happens, `with_settings()` can silently overwrite a manually assigned value because it has no way to know the field was set explicitly. The same pattern exists in the mirrored replace/delete builders, so the current public API no longer enforces the behavior its new documentation promises.
| describe('prepareDocumentCreate()', () => { | ||
| it('rejects a document with no entropy', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument(); | ||
| // Entropy defaults to a generated 32-byte value in the constructor; clear it | ||
| // to hit the "must have entropy set" guard in prepare_document_create. | ||
| document.entropy = null; | ||
|
|
||
| try { | ||
| await client.prepareDocumentCreate({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentCreate to reject without entropy'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/entropy/i); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a document whose entropy is not 32 bytes', async () => { | ||
| // NOTE: the Document constructor and the entropy setter both reject | ||
| // non-32-byte buffers, so the defensive length check inside | ||
| // prepare_document_create is unreachable from JS under normal use. We | ||
| // assert the outer guard here — that the SDK refuses bad entropy | ||
| // *somewhere* before broadcasting — which is the behavior callers care | ||
| // about. | ||
| try { | ||
| const document = buildDocument(); | ||
| document.entropy = new Uint8Array(16); | ||
| expect.fail('expected Document entropy setter to reject a 16-byte buffer'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/entropy/i); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a document with revision > INITIAL_REVISION (would silently be a replace)', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument({ revision: 2 }); | ||
|
|
||
| try { | ||
| await client.prepareDocumentCreate({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentCreate to reject revision > 1'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision/i); | ||
| expect(e.message).to.match(/prepareDocumentReplace/); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a document whose id does not match its entropy', async () => { | ||
| // The strict create path requires document.id to be derived from | ||
| // (dataContractId, ownerId, documentTypeName, entropy) via the v0 | ||
| // document-id derivation. Manually overwriting `id` after construction | ||
| // breaks that invariant and must fail with InvalidArgument before | ||
| // any nonce allocation. | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument(); | ||
| document.id = DUMMY_ID; | ||
|
|
||
| try { | ||
| await client.prepareDocumentCreate({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentCreate to reject mismatched id/entropy'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/does not match/i); | ||
| } | ||
| }); | ||
|
|
||
| it('accepts a plain-object document and applies the same validation', async () => { | ||
| // Regression test for the safe structural extraction path. The | ||
| // prepare API must accept a plain `{...}` document shape *without* | ||
| // dereferencing wasm-bindgen internals — but it must still enforce | ||
| // the same revision guard a real `Document` instance hits. Hand the | ||
| // SDK a plain object with revision 2 and assert it routes to the | ||
| // revision guard, not the `__wbg_ptr` fast path. | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = { | ||
| id: DUMMY_ID, | ||
| ownerId: DUMMY_ID_2, | ||
| dataContractId: DUMMY_ID, | ||
| documentTypeName: 'note', | ||
| properties: { message: 'hello' }, | ||
| revision: 2, | ||
| entropy: new Uint8Array(32), | ||
| }; | ||
|
|
||
| try { | ||
| await client.prepareDocumentCreate({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentCreate to reject revision > 1'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision/i); | ||
| expect(e.message).to.match(/prepareDocumentReplace/); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a document with revision 0 (would silently be a replace)', async () => { | ||
| // Revision 0 is invalid for *both* create and replace, so the shared | ||
| // rejection message must not point users at the sibling API (which | ||
| // would also reject). It must say "revision 0 is invalid for both | ||
| // create and replace" so callers see the always-invalid value | ||
| // explicitly instead of being routed in a loop. | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument({ revision: 0 }); | ||
|
|
||
| try { | ||
| await client.prepareDocumentCreate({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentCreate to reject revision 0'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision 0 is invalid for both create and replace/); | ||
| expect(e.message).to.not.match(/prepareDocumentReplace/); | ||
| expect(e.message).to.not.match(/prepareDocumentCreate/); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe('prepareDocumentReplace()', () => { | ||
| it('rejects a document with no revision', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument(); | ||
| // The Document constructor defaults revision to 1; clear it to exercise | ||
| // the "must have a revision set" guard in prepare_document_replace. | ||
| document.revision = null; | ||
|
|
||
| try { | ||
| await client.prepareDocumentReplace({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentReplace to reject missing revision'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision/i); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a document with revision 0', async () => { | ||
| // Revision 0 is invalid for *both* create and replace, so the shared | ||
| // rejection message must not point users at the sibling API (which | ||
| // would also reject). It must say "revision 0 is invalid for both | ||
| // create and replace" so callers see the always-invalid value | ||
| // explicitly instead of being routed in a loop. | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument({ revision: 0 }); | ||
|
|
||
| try { | ||
| await client.prepareDocumentReplace({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentReplace to reject revision 0'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision 0 is invalid for both create and replace/); | ||
| expect(e.message).to.not.match(/prepareDocumentCreate/); | ||
| expect(e.message).to.not.match(/prepareDocumentReplace/); | ||
| } | ||
| }); | ||
|
|
||
| it('accepts a plain-object document and applies the same validation', async () => { | ||
| // Regression test for the safe structural extraction path. The | ||
| // prepare API must accept a plain `{...}` document shape *without* | ||
| // dereferencing wasm-bindgen internals — but it must still enforce | ||
| // the same revision guard a real `Document` instance hits. Hand the | ||
| // SDK a plain object with revision 1 and assert it routes to the | ||
| // replace revision guard. | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = { | ||
| id: DUMMY_ID, | ||
| ownerId: DUMMY_ID_2, | ||
| dataContractId: DUMMY_ID, | ||
| documentTypeName: 'note', | ||
| properties: { message: 'hello' }, | ||
| revision: 1, | ||
| }; | ||
|
|
||
| try { | ||
| await client.prepareDocumentReplace({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentReplace to reject revision 1'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision/i); | ||
| expect(e.message).to.match(/prepareDocumentCreate/); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a document with revision 1 (INITIAL_REVISION)', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
| const document = buildDocument({ revision: 1 }); | ||
|
|
||
| try { | ||
| await client.prepareDocumentReplace({ document, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentReplace to reject revision 1'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/revision/i); | ||
| expect(e.message).to.match(/prepareDocumentCreate/); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe('prepareDocumentDelete()', () => { | ||
| it('rejects when document is missing', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
|
|
||
| try { | ||
| await client.prepareDocumentDelete({ identityKey, signer }); | ||
| expect.fail('expected prepareDocumentDelete to reject without document'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/document is required/i); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects when document is null', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
|
|
||
| try { | ||
| await client.prepareDocumentDelete({ document: null, identityKey, signer }); | ||
| expect.fail('expected prepareDocumentDelete to reject null document'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/document is required/i); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a plain object with no id', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
|
|
||
| try { | ||
| await client.prepareDocumentDelete({ | ||
| document: { | ||
| ownerId: DUMMY_ID_2, | ||
| dataContractId: DUMMY_ID, | ||
| documentTypeName: 'note', | ||
| }, | ||
| identityKey, | ||
| signer, | ||
| }); | ||
| expect.fail('expected prepareDocumentDelete to reject missing id'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/id/i); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a plain object with no ownerId', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
|
|
||
| try { | ||
| await client.prepareDocumentDelete({ | ||
| document: { | ||
| id: DUMMY_ID, | ||
| dataContractId: DUMMY_ID, | ||
| documentTypeName: 'note', | ||
| }, | ||
| identityKey, | ||
| signer, | ||
| }); | ||
| expect.fail('expected prepareDocumentDelete to reject missing ownerId'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/ownerId/i); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a plain object with no dataContractId', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
|
|
||
| try { | ||
| await client.prepareDocumentDelete({ | ||
| document: { | ||
| id: DUMMY_ID, | ||
| ownerId: DUMMY_ID_2, | ||
| documentTypeName: 'note', | ||
| }, | ||
| identityKey, | ||
| signer, | ||
| }); | ||
| expect.fail('expected prepareDocumentDelete to reject missing dataContractId'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/dataContractId/i); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a plain object with no documentTypeName', async () => { | ||
| const { signer, identityKey } = buildSigner(); | ||
|
|
||
| try { | ||
| await client.prepareDocumentDelete({ | ||
| document: { | ||
| id: DUMMY_ID, | ||
| ownerId: DUMMY_ID_2, | ||
| dataContractId: DUMMY_ID, | ||
| }, | ||
| identityKey, | ||
| signer, | ||
| }); | ||
| expect.fail('expected prepareDocumentDelete to reject missing documentTypeName'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/documentTypeName/i); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a Document instance when identityKey has the wrong shape', async () => { | ||
| const { signer } = buildSigner(); | ||
| const document = buildDocument({ revision: 1 }); | ||
|
|
||
| try { | ||
| await client.prepareDocumentDelete({ | ||
| document, | ||
| identityKey: {}, | ||
| signer, | ||
| }); | ||
| expect.fail('expected prepareDocumentDelete to reject invalid identityKey'); | ||
| } catch (e) { | ||
| expect(e).to.be.instanceOf(sdk.WasmSdkError); | ||
| expect(e.name).to.equal('InvalidArgument'); | ||
| expect(e.message).to.match(/identityKey/i); | ||
| } | ||
| }); | ||
| }); |
There was a problem hiding this comment.
🟡 Suggestion: The new structural FFI hardening is not pinned by adversarial regression tests
The new extraction helpers in packages/wasm-sdk/src/state_transitions/document.rs explicitly avoid DocumentWasm::try_from, TokenPaymentInfoWasm::try_from, and similar pointer-trusting conversions on public JS input. The unit suite covers plain-object success/failure paths, but it never passes forged inputs that spoof __type and __wbg_ptr for document, delete identifier fields, or tokenPaymentInfo. Without those adversarial tests, a future refactor could accidentally reintroduce a __wbg_ptr dereference on untrusted input while keeping the current test suite green.
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [SUGGESTION] lines 67-400: The new structural FFI hardening is not pinned by adversarial regression tests
The new extraction helpers in `packages/wasm-sdk/src/state_transitions/document.rs` explicitly avoid `DocumentWasm::try_from`, `TokenPaymentInfoWasm::try_from`, and similar pointer-trusting conversions on public JS input. The unit suite covers plain-object success/failure paths, but it never passes forged inputs that spoof `__type` and `__wbg_ptr` for `document`, delete identifier fields, or `tokenPaymentInfo`. Without those adversarial tests, a future refactor could accidentally reintroduce a `__wbg_ptr` dereference on untrusted input while keeping the current test suite green.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
I verified the reported WASM interop concern against 0d84e9b62b35b320fb4840ecd1f1076151b9739e. The cited identityKey/signer conversions do exist, but they are a documented, intentional trust boundary that already applies to both the one-shot and prepare document APIs in this module; this PR does not introduce a new dereference path or broaden that contract. I did not confirm any PR-correct blocking or suggestion-level findings.
Reviewed commit: 0d84e9b
Issue
Closes #3090
Problem
The high-level document APIs (
documentCreate,documentReplace,documentDelete) in the WASM SDK atomically bundle nonce management, ST construction, signing, broadcasting, and waiting. On timeout, callers cannot rebroadcast the same signed ST — retrying creates a duplicate with a new nonce.Solution
Implements Option A (Two-Phase API) from the issue: add
prepare_*variants for each document operation that return a signedStateTransitionwithout broadcasting:prepareDocumentCreate()— build, sign, return STprepareDocumentReplace()— build, sign, return STprepareDocumentDelete()— build, sign, return STThese pair with the already-existing
broadcastStateTransition()andwaitForResponse()methods inbroadcast.rs.Usage Pattern
This gives applications full control over retry and caching strategy while leveraging Platform's built-in duplicate ST rejection.
Changes
packages/wasm-sdk/src/state_transitions/document.rs:prepareDocumentCreate()— builds and signs a create ST without broadcastingprepareDocumentReplace()— builds and signs a replace ST without broadcastingprepareDocumentDelete()— builds and signs a delete ST without broadcastingCompatibility note: one-shot revision guards
The existing one-shot
documentCreate()anddocumentReplace()APIs remain available, but this PR now rejects revision/API mismatches instead of silently routing them through the opposite transition type. In particular:documentCreate()accepts an unset revision orINITIAL_REVISION; documents with any other explicit revision now fail withInvalidArgumentinstead of being treated as replace transitions.documentReplace()requires a revision greater thanINITIAL_REVISION; missing, zero, or initial revisions now fail withInvalidArgumentinstead of being treated as create transitions.Consumers that reused stale documents or relied on the previous silent create/replace routing should audit those call sites before upgrading.
Rust SDK consumers should also note the related
PutDocument::put_to_platformtightening: replace calls withrevision = Some(0)are now rejected instead of being routed through the create branch, and create calls that passdocument_state_transition_entropy = Some(entropy)now validate thatdocument.idmatchesDocument::generate_document_id_v0(...)before nonce allocation. The in-treers-platform-walletcaller usesrevision: Nonefor creates andSome(current_revision + 1)for updates, so it is unaffected.Compatibility note: structure validation error ordering
ensure_valid_state_transition_structurenow treats DPP's structure-validation sentinel (UnsupportedFeatureErrorentries whose feature name starts with"structure validation") as a no-op only when every error is that sentinel. Mixed validation results are not filtered: every original diagnostic is preserved, but non-UnsupportedFeatureErrorfailures are reordered ahead of unsupported-feature entries so the typed primary error returned to callers is the actionable validation failure. Non-sentinelUnsupportedFeatureErrorentries (for example, unsupported sub-features inside an otherwise validated transition) still surface as real errors.Compatibility note: native document builders
Native rs-sdk document create/replace builders now honor
PutSettings.user_fee_increaseandPutSettings.state_transition_creation_optionspassed throughwith_settings(...)when dedicated builder setters were not used, matching the delete builder and high-level SDK paths. Delete buildersign(...)now rolls back allocated identity-contract nonces on pre-broadcast failures, andsign_with_nonce(...)runs local structure validation before returning.Testing
The existing document operation tests validate the build/sign/broadcast pipeline. The prepare variants reuse the same construction logic, stopping before broadcast. The
broadcastStateTransitionandwaitForResponsemethods are already tested inbroadcast.rs. Manual testing with the yappr application (which prompted this issue) confirms the two-phase pattern works correctly.Validation
Build verification
# WASM SDK builds successfully with new prepare_* APIs cargo check -p wasm-sdkExisting test coverage
The
prepare_*methods reuse the same internal construction paths as the existing all-in-one document operations:prepareDocumentCreate/prepareDocumentReplacedelegate to strict rs-sdk helpers that validate create-vs-replace intent, validate create entropy/document-id consistency, allocate the identity-contract nonce, build, sign, and roll back the nonce on pre-broadcast failuresprepareDocumentDeleteuses the shared rs-sdk delete helper, the same build/sign/validate path used by the existingdocumentDeleteflowbroadcastStateTransition()andwaitForResponse()(the "execute" half) are already tested inbroadcast.rsExisting CI test suites (
cargo test -p wasm-sdk, platform integration tests) validate these shared code paths.Manual / integration testing
prepareDocumentCreate()returns a signedStateTransitiontoBytes(), cached, then broadcast viabroadcastStateTransition()Input validation
prepareDocumentReplacerejects documents withINITIAL_REVISION(guards against accidental create-as-replace)prepareDocumentCreatevalidates entropy is present and exactly 32 bytesprepareDocumentDeleteaccepts both fullDocumentinstances and minimal identifier objectsSummary by CodeRabbit